diff --git a/internal/engine/collection.go b/internal/engine/collection.go new file mode 100644 index 0000000..94dcaf7 --- /dev/null +++ b/internal/engine/collection.go @@ -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", "
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 + } + + // 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 +} diff --git a/internal/engine/content.go b/internal/engine/content.go new file mode 100644 index 0000000..e4f74eb --- /dev/null +++ b/internal/engine/content.go @@ -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..." +} diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go new file mode 100644 index 0000000..bf230e5 --- /dev/null +++ b/internal/engine/discovery.go @@ -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) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 59fd7ac..0491f1d 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -7,7 +7,6 @@ import ( "github.com/insertr/insertr/internal/db" "golang.org/x/net/html" - "slices" ) // AuthProvider represents authentication provider information @@ -141,629 +140,3 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro 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 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", "
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 { - 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 -}