package engine import ( "context" "fmt" "strings" "github.com/insertr/insertr/internal/db" "golang.org/x/net/html" "slices" ) // AuthProvider represents authentication provider information type AuthProvider struct { Type string // "mock", "jwt", "authentik" } // ContentEngine is the unified content processing engine type ContentEngine struct { idGenerator *IDGenerator client db.ContentRepository authProvider *AuthProvider injector *Injector } // NewContentEngine creates a new content processing engine func NewContentEngine(client db.ContentRepository) *ContentEngine { authProvider := &AuthProvider{Type: "mock"} // default return &ContentEngine{ idGenerator: NewIDGenerator(), client: client, authProvider: authProvider, injector: NewInjector(client, "", nil), // siteID will be set per operation } } // NewContentEngineWithAuth creates a new content processing engine with auth config func NewContentEngineWithAuth(client db.ContentRepository, authProvider *AuthProvider) *ContentEngine { if authProvider == nil { authProvider = &AuthProvider{Type: "mock"} } return &ContentEngine{ idGenerator: NewIDGenerator(), client: client, authProvider: authProvider, injector: NewInjectorWithAuth(client, "", authProvider, nil), // siteID will be set per operation } } // ProcessContent processes HTML content according to the specified mode func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, error) { // 1. Parse HTML doc, err := html.Parse(strings.NewReader(string(input.HTML))) if err != nil { return nil, fmt.Errorf("parsing HTML: %w", err) } // 2. Find insertr and collection elements insertrElements, collectionElements := e.findEditableElements(doc) // 3. Process regular .insertr elements generatedIDs := make(map[string]string) processedElements := make([]ProcessedElement, len(insertrElements)) for i, elem := range insertrElements { // Generate structural ID (always deterministic) id := e.idGenerator.Generate(elem.Node, input.FilePath) // Database-first approach: Check if content already exists existingContent, err := e.client.GetContent(context.Background(), input.SiteID, id) contentExists := (err == nil && existingContent != nil) generatedIDs[fmt.Sprintf("element_%d", i)] = id processedElements[i] = ProcessedElement{ Node: elem.Node, ID: id, Generated: !contentExists, // Mark as generated only if new to database Tag: elem.Node.Data, Classes: GetClasses(elem.Node), } // Add/update content attributes to the node (only content-id now) e.addContentAttributes(elem.Node, id) // Store content only for truly new elements (database-first check) if !contentExists && (input.Mode == Enhancement || input.Mode == ContentInjection) { // Extract content and template from the unprocessed element htmlContent := e.extractHTMLContent(elem.Node) originalTemplate := e.extractOriginalTemplate(elem.Node) // Store in database via content client _, err := e.client.CreateContent(context.Background(), input.SiteID, id, htmlContent, originalTemplate, "system") if err != nil { // Log error but don't fail the enhancement - content just won't be stored fmt.Printf("⚠️ Failed to store content for %s: %v\n", id, err) } else { fmt.Printf("✅ Created new content: %s (html)\n", id) } } } // 4. Process .insertr-add collection elements for _, collectionElem := range collectionElements { // Generate structural ID for the collection container collectionID := e.idGenerator.Generate(collectionElem.Node, input.FilePath) // Add data-collection-id attribute to the collection container e.setAttribute(collectionElem.Node, "data-collection-id", collectionID) // Process collection during enhancement or content injection if input.Mode == Enhancement || input.Mode == ContentInjection { err := e.processCollection(collectionElem.Node, collectionID, input.SiteID) if err != nil { fmt.Printf("⚠️ Failed to process collection %s: %v\n", collectionID, err) } else { fmt.Printf("✅ Processed collection: %s\n", collectionID) } } } // 6. Inject content if required by mode if input.Mode == Enhancement || input.Mode == ContentInjection { err = e.injectContent(processedElements, input.SiteID) if err != nil { return nil, fmt.Errorf("injecting content: %w", err) } } // TODO: Implement collection-specific content injection here if needed // 6. Inject editor assets for enhancement mode (development) if input.Mode == Enhancement { injector := NewInjectorWithAuth(e.client, input.SiteID, e.authProvider, nil) injector.InjectEditorAssets(doc, true, "") } return &ContentResult{ Document: doc, Elements: processedElements, GeneratedIDs: generatedIDs, }, 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 e.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 { e.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) } } // hasInsertrClass checks if node has class="insertr" func (e *ContentEngine) hasInsertrClass(node *html.Node) bool { classes := GetClasses(node) return slices.Contains(classes, "insertr") } // 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 // HTML-first approach: no content-type attribute needed func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string) { // Add data-content-id attribute e.setAttribute(node, "data-content-id", contentID) } // setAttribute sets an attribute on an HTML node func (e *ContentEngine) setAttribute(node *html.Node, key, value string) { // Remove existing attribute if it exists for i, attr := range node.Attr { if attr.Key == key { node.Attr[i].Val = value return } } // Add new attribute node.Attr = append(node.Attr, html.Attribute{ Key: key, Val: value, }) } // addClass safely adds a class to an HTML node func (e *ContentEngine) addClass(node *html.Node, className string) { var classAttr *html.Attribute var classIndex int = -1 // Find existing class attribute for idx, attr := range node.Attr { if attr.Key == "class" { classAttr = &attr classIndex = idx break } } var classes []string if classAttr != nil { classes = strings.Fields(classAttr.Val) } // Check if class already exists for _, class := range classes { if class == className { return // Class already exists } } // Add new class classes = append(classes, className) newClassValue := strings.Join(classes, " ") if classIndex >= 0 { // Update existing class attribute node.Attr[classIndex].Val = newClassValue } else { // Add new class attribute node.Attr = append(node.Attr, html.Attribute{ Key: "class", Val: newClassValue, }) } } // 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 = append(node.Attr[:classIndex], node.Attr[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 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 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 = append(n.Attr[:i], n.Attr[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) for _, class := range classes { if class == 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", "
New item
", 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 { e.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 e.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 }