Files
insertr/internal/engine/engine.go
Joakim 2315ba4750 Implement complete collection persistence with database-backed survival across server restarts
• Add full multi-table schema for collections with normalized design (collections, collection_templates, collection_items, collection_item_versions)
• Implement collection detection and processing in enhancement pipeline for .insertr-add elements
• Add template extraction and storage from existing HTML children with multi-variant support
• Enable collection reconstruction from database on server restart with proper DOM rebuilding
• Extend ContentClient interface with collection operations and full database integration
• Update enhance command to use engine.DatabaseClient for collection persistence support
2025-09-22 18:29:58 +02:00

517 lines
16 KiB
Go

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 from existing children
err = e.extractAndStoreTemplates(collectionNode, collectionID, siteID)
if err != nil {
return fmt.Errorf("failed to extract templates for collection %s: %w", collectionID, err)
}
fmt.Printf("✅ Created new collection: %s with templates\n", collectionID)
} else {
// 4. Existing collection: reconstruct items from database
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\n", collectionID)
}
return nil
}
// extractAndStoreTemplates extracts template patterns from existing collection children
func (e *ContentEngine) extractAndStoreTemplates(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
for i, templateElement := range templateElements {
templateHTML := e.extractOriginalTemplate(templateElement)
templateName := fmt.Sprintf("template-%d", i+1)
isDefault := (i == 0) // First template is default
_, err := e.client.CreateCollectionTemplate(siteID, collectionID, templateName, templateHTML, isDefault)
if err != nil {
return fmt.Errorf("failed to create template %s: %w", templateName, err)
}
fmt.Printf("✅ Created template '%s' for collection %s\n", templateName, collectionID)
}
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)
}
// 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 {
// Parse the item HTML content
itemDoc, err := html.Parse(strings.NewReader(item.HTMLContent))
if err != nil {
fmt.Printf("⚠️ Failed to parse item HTML for %s: %v\n", item.ItemID, err)
continue
}
// Find the body element and extract its children
var bodyNode *html.Node
e.walkNodes(itemDoc, func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "body" {
bodyNode = n
}
})
if bodyNode != nil {
// Move all children from body to collection
for bodyChild := bodyNode.FirstChild; bodyChild != nil; {
next := bodyChild.NextSibling
bodyNode.RemoveChild(bodyChild)
collectionNode.AppendChild(bodyChild)
bodyChild = next
}
}
}
fmt.Printf("✅ Reconstructed %d items for collection %s\n", len(items), collectionID)
return nil
}