Files
insertr/internal/engine/engine.go

678 lines
22 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"fmt"
"strings"
"golang.org/x/net/html"
)
// 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 ContentClient
authProvider *AuthProvider
injector *Injector
}
// NewContentEngine creates a new content processing engine
func NewContentEngine(client ContentClient) *ContentEngine {
authProvider := &AuthProvider{Type: "mock"} // default
return &ContentEngine{
idGenerator: NewIDGenerator(),
client: client,
authProvider: authProvider,
injector: NewInjector(client, ""), // siteID will be set per operation
}
}
// NewContentEngineWithAuth creates a new content processing engine with auth config
func NewContentEngineWithAuth(client ContentClient, authProvider *AuthProvider) *ContentEngine {
if authProvider == nil {
authProvider = &AuthProvider{Type: "mock"}
}
return &ContentEngine{
idGenerator: NewIDGenerator(),
client: client,
authProvider: authProvider,
injector: NewInjectorWithAuth(client, "", authProvider), // 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(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(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-content-id attribute to the collection container
e.setAttribute(collectionElem.Node, "data-content-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)
}
}
}
// 5. 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)
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,
})
}
} else 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
}
// findInsertrElements finds all elements with class="insertr" and applies container transformation
// This implements the "syntactic sugar transformation" from CLASSES.md:
// - Containers with .insertr get their .insertr class removed
// - Viable children of those containers get .insertr class added
// - Regular elements with .insertr are kept as-is
func (e *ContentEngine) findInsertrElements(doc *html.Node) []InsertrElement {
insertrElements, _ := e.findEditableElements(doc)
return insertrElements
}
// 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)
for _, class := range classes {
if class == "insertr" {
return true
}
}
return false
}
// hasInsertrAddClass checks if node has class="insertr-add" (collection)
func (e *ContentEngine) hasInsertrAddClass(node *html.Node) bool {
classes := GetClasses(node)
for _, class := range classes {
if class == "insertr-add" {
return true
}
}
return false
}
// 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)
}
// getAttribute gets an attribute value from an HTML node
func (e *ContentEngine) getAttribute(node *html.Node, key string) string {
for _, attr := range node.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
// 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(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()
}
// 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(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(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. Database-first approach: Check if collection items already exist
existingItems, err := e.client.GetCollectionItems(siteID, collectionID)
if err != nil {
return fmt.Errorf("failed to check existing collection items: %w", err)
}
if len(existingItems) == 0 {
// 5. Collection exists but no items - store original children as initial items
err = e.storeInitialCollectionItems(collectionNode, collectionID, siteID)
if err != nil {
return fmt.Errorf("failed to store initial collection items for %s: %w", collectionID, err)
}
fmt.Printf("✅ Stored initial items for existing collection: %s\n", collectionID)
} else {
// 6. Items exist: reconstruct from database (normal case)
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
if err != nil {
return fmt.Errorf("failed to reconstruct collection %s: %w", collectionID, err)
}
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(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.extractOriginalTemplate(templateElement)
templateName := fmt.Sprintf("template-%d", i+1)
isDefault := (i == 0) // First template is default
template, err := e.client.CreateCollectionTemplate(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)
}
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(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(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
for _, item := range items {
// Get the template for this item
templateHTML, exists := templateMap[item.TemplateID]
if !exists {
fmt.Printf("⚠️ Template %d not found for item %s\n", item.TemplateID, item.ItemID)
continue
}
// Parse the template HTML
templateDoc, err := html.Parse(strings.NewReader(templateHTML))
if err != nil {
fmt.Printf("⚠️ Failed to parse template HTML for %s: %v\n", item.ItemID, err)
continue
}
// 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 {
// Clone the template structure (first child of body)
templateNode := templateBody.FirstChild
clonedTemplate := e.cloneNode(templateNode)
// Replace the template's inner content with the stored item content
// Clear the cloned template's children
for child := clonedTemplate.FirstChild; child != nil; {
next := child.NextSibling
clonedTemplate.RemoveChild(child)
child = next
}
// Parse and add the item's content
itemDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
if err != nil {
fmt.Printf("⚠️ Failed to parse item content for %s: %v\n", item.ItemID, err)
continue
}
var itemBody *html.Node
e.walkNodes(itemDoc, func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "body" {
itemBody = n
}
})
if itemBody != nil {
// Move all children from item body to cloned template
for itemChild := itemBody.FirstChild; itemChild != nil; {
next := itemChild.NextSibling
itemBody.RemoveChild(itemChild)
clonedTemplate.AppendChild(itemChild)
itemChild = next
}
}
// Add the reconstructed item to collection
collectionNode.AppendChild(clonedTemplate)
}
}
fmt.Printf("✅ Reconstructed %d items for collection %s\n", len(items), collectionID)
return 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
}
// storeInitialCollectionItems stores original children as collection items (for existing collections)
func (e *ContentEngine) storeInitialCollectionItems(collectionNode *html.Node, collectionID, siteID string) error {
// Get existing templates for this collection
templates, err := e.client.GetCollectionTemplates(siteID, collectionID)
if err != nil {
return fmt.Errorf("failed to get collection templates: %w", err)
}
if len(templates) == 0 {
fmt.Printf("⚠️ No templates found for collection %s, skipping initial items storage\n", collectionID)
return nil
}
// Use template IDs from existing templates
var templateIDs []int
for _, template := range templates {
templateIDs = append(templateIDs, template.TemplateID)
}
return e.storeChildrenAsCollectionItems(collectionNode, collectionID, siteID, templateIDs)
}
// 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 as a collection item (database-first pattern like .insertr)
for i, childElement := range childElements {
// Generate item ID (like content ID generation)
itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1)
// Extract HTML content from the child (reuse .insertr pattern)
htmlContent := e.extractHTMLContent(childElement)
// Use appropriate template ID (cycle through available templates)
templateID := templateIDs[i%len(templateIDs)]
// Store as collection item
_, err := e.client.CreateCollectionItem(siteID, collectionID, itemID, templateID, htmlContent, 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)\n", itemID, templateID)
}
return nil
}