fix: systematic element matching bug in enhancement pipeline

- Problem: Element ID collisions between similar elements (logo h1 vs hero h1)
  causing content to be injected into wrong elements
- Root cause: Enhancer used naive tag+class matching instead of parser's
  sophisticated semantic analysis for element identification

Systematic solution:
- Enhanced parser architecture with exported utilities (GetClasses, ContainsClass)
- Added FindElementInDocument() with content-based semantic matching
- Replaced naive findAndInjectNodes() with parser-based element matching
- Removed code duplication between parser and enhancer packages

Backend improvements:
- Moved ID generation to backend for single source of truth
- Added ElementContext struct for frontend-backend communication
- Updated API handlers to support context-based content ID generation

Frontend improvements:
- Enhanced getElementMetadata() to extract semantic context
- Updated save flow to handle both enhanced and non-enhanced elements
- Improved API client to use backend-generated content IDs

Result:
- Unique content IDs: navbar-logo-200530 vs hero-title-a1de7b
- Precise element matching using content validation
- Single source of truth for DOM utilities in parser package
- Eliminated 40+ lines of duplicate code while fixing core bug
This commit is contained in:
2025-09-11 14:14:57 +02:00
parent f73e21ce6e
commit ef1d1083ce
12 changed files with 575 additions and 314 deletions

163
cmd/restore.go Normal file
View File

@@ -0,0 +1,163 @@
package cmd
import (
"fmt"
"log"
"sort"
"github.com/insertr/insertr/internal/content"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
timestamp string
latest bool
clean bool
)
var restoreCmd = &cobra.Command{
Use: "restore [site-id]",
Short: "Restore a site from backup",
Long: `Restore a registered site from a timestamped backup.
Examples:
insertr restore demo # List available backups
insertr restore demo --clean # Restore from oldest backup (cleanest)
insertr restore demo --latest # Restore from newest backup
insertr restore demo --timestamp 20250910-224704 # Restore from specific backup`,
Args: cobra.ExactArgs(1),
Run: runRestore,
}
func init() {
restoreCmd.Flags().StringVarP(&timestamp, "timestamp", "t", "", "specific backup timestamp to restore from")
restoreCmd.Flags().BoolVar(&latest, "latest", false, "restore from most recent backup")
restoreCmd.Flags().BoolVar(&clean, "clean", false, "restore from oldest backup (cleanest state)")
// Bind flags to viper
viper.BindPFlag("restore.timestamp", restoreCmd.Flags().Lookup("timestamp"))
viper.BindPFlag("restore.latest", restoreCmd.Flags().Lookup("latest"))
viper.BindPFlag("restore.clean", restoreCmd.Flags().Lookup("clean"))
}
func runRestore(cmd *cobra.Command, args []string) {
siteID := args[0]
// Initialize content client (we don't actually need it for restore, but SiteManager expects it)
contentClient := content.NewMockClient()
// Initialize site manager
siteManager := content.NewSiteManager(contentClient, "./insertr-backups", false)
// Load sites from configuration to register them
if siteConfigs := viper.Get("server.sites"); siteConfigs != nil {
if configs, ok := siteConfigs.([]interface{}); ok {
var sites []*content.SiteConfig
for _, configInterface := range configs {
if configMap, ok := configInterface.(map[string]interface{}); ok {
site := &content.SiteConfig{}
if id, ok := configMap["site_id"].(string); ok {
site.SiteID = id
}
if path, ok := configMap["path"].(string); ok {
site.Path = path
}
if domain, ok := configMap["domain"].(string); ok {
site.Domain = domain
}
if autoEnhance, ok := configMap["auto_enhance"].(bool); ok {
site.AutoEnhance = autoEnhance
}
if backupOriginals, ok := configMap["backup_originals"].(bool); ok {
site.BackupOriginals = backupOriginals
}
sites = append(sites, site)
}
}
if err := siteManager.RegisterSites(sites); err != nil {
log.Fatalf("Failed to register sites: %v", err)
}
}
}
// List available backups
backups, err := siteManager.ListBackups(siteID)
if err != nil {
log.Fatalf("Failed to list backups: %v", err)
}
if len(backups) == 0 {
fmt.Printf("❌ No backups found for site '%s'\n", siteID)
fmt.Printf("💡 Backups are created automatically during enhancement when backup_originals is enabled\n")
return
}
// Sort backups chronologically
sort.Strings(backups)
// Handle different restore modes
var targetTimestamp string
if timestamp != "" {
// Specific timestamp provided
targetTimestamp = timestamp
found := false
for _, backup := range backups {
if backup == targetTimestamp {
found = true
break
}
}
if !found {
fmt.Printf("❌ Backup timestamp '%s' not found for site '%s'\n", targetTimestamp, siteID)
fmt.Printf("📋 Available backups:\n")
for i, backup := range backups {
if i == 0 {
fmt.Printf(" %s (oldest/cleanest)\n", backup)
} else if i == len(backups)-1 {
fmt.Printf(" %s (newest)\n", backup)
} else {
fmt.Printf(" %s\n", backup)
}
}
return
}
} else if clean {
// Restore from oldest backup (cleanest)
targetTimestamp = backups[0]
fmt.Printf("🧹 Restoring from oldest backup (cleanest state): %s\n", targetTimestamp)
} else if latest {
// Restore from newest backup
targetTimestamp = backups[len(backups)-1]
fmt.Printf("🔄 Restoring from newest backup: %s\n", targetTimestamp)
} else {
// No specific option - list available backups
fmt.Printf("📋 Available backups for site '%s':\n", siteID)
for i, backup := range backups {
if i == 0 {
fmt.Printf(" %s (oldest/cleanest) ← use --clean\n", backup)
} else if i == len(backups)-1 {
fmt.Printf(" %s (newest) ← use --latest\n", backup)
} else {
fmt.Printf(" %s\n", backup)
}
}
fmt.Printf("\nUsage:\n")
fmt.Printf(" insertr restore %s --clean # restore from oldest backup\n", siteID)
fmt.Printf(" insertr restore %s --latest # restore from newest backup\n", siteID)
fmt.Printf(" insertr restore %s --timestamp %s # restore from specific backup\n", siteID, backups[0])
return
}
// Perform restore
fmt.Printf("🔄 Restoring site '%s' from backup %s...\n", siteID, targetTimestamp)
if err := siteManager.RestoreFromBackup(siteID, targetTimestamp); err != nil {
log.Fatalf("❌ Restore failed: %v", err)
}
fmt.Printf("✅ Successfully restored site '%s' from backup %s\n", siteID, targetTimestamp)
fmt.Printf("💡 Site files have been restored to their state from %s\n", targetTimestamp)
}

View File

@@ -1,10 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>About - Acme Consulting Services</title>
<link rel="stylesheet" href="assets/style.css">
<link rel="stylesheet" href="assets/style.css"/>
</head>
<body>
@@ -25,7 +23,7 @@
<section class="hero">
<div class="container">
<h1 class="insertr" >About Acme Consulting</h1>
<p class="lead insertr">We're a team of experienced consultants dedicated to helping small businesses thrive in today's competitive marketplace.</p>
<p class="lead insertr" >We&#39;re a team of experienced consultants dedicated to helping small businesses thrive in today&#39;s competitive marketplace.</p>
</div>
</section>
@@ -36,9 +34,9 @@
<div class="insertr-group">
<p>Founded in 2020, Acme Consulting emerged from a simple observation: small businesses needed access to the same high-quality strategic advice that large corporations receive, but in a format that was accessible, affordable, and actionable.</p>
<p>Our founders, with combined experience of over 30 years in business strategy, operations, and technology, recognized that the traditional consulting model wasn't serving the needs of growing businesses. We set out to change that.</p>
<p>Our founders, with combined experience of over 30 years in business strategy, operations, and technology, recognized that the traditional consulting model wasn&#39;t serving the needs of growing businesses. We set out to change that.</p>
<p>Today, we've helped over **200 businesses** streamline their operations, clarify their strategy, and achieve sustainable growth. Our approach combines proven methodologies with a deep understanding of the unique challenges facing small to medium-sized businesses.</p>
<p>Today, we&#39;ve helped over **200 businesses** streamline their operations, clarify their strategy, and achieve sustainable growth. Our approach combines proven methodologies with a deep understanding of the unique challenges facing small to medium-sized businesses.</p>
</div>
</div>
</section>
@@ -47,13 +45,13 @@
<section class="cta">
<div class="container">
<h2 class="insertr" >Our Team</h2>
<p class="insertr">We're a diverse group of strategists, operators, and technology experts united by our passion for helping businesses succeed.</p>
<p class="insertr" >We&#39;re a diverse group of strategists, operators, and technology experts united by our passion for helping businesses succeed.</p>
<div class="services-grid" style="margin-top: 3rem;">
<div class="service-card">
<div class="insertr">
<h3>Sarah Chen</h3>
<p><strong>Founder & CEO</strong></p>
<p><strong>Founder &amp; CEO</strong></p>
<p>Former <strong>McKinsey consultant</strong> with 15 years of experience in strategy and operations. MBA from Stanford.</p>
</div>
</div>
@@ -90,7 +88,7 @@
</div>
<div>
<h3 class="insertr" >Long-term Partnership</h3>
<p class="insertr">We're not just consultants; we're partners in your business success for the long haul.</p>
<p class="insertr" >We&#39;re not just consultants; we&#39;re partners in your business success for the long haul.</p>
</div>
</div>
</div>
@@ -126,7 +124,7 @@
<!-- Footer -->
<footer class="footer">
<div class="container">
<p class="insertr">&copy; 2024 Acme Consulting Services. All rights reserved.</p>
<p class="insertr" >© 2024 Acme Consulting Services. All rights reserved.</p>
<p class="insertr" >📧 info@acmeconsulting.com | 📞 (555) 123-4567 | <button class="insertr-gate" style="background: none; border: 1px solid #ccc; padding: 4px 8px; margin-left: 10px; border-radius: 3px; font-size: 11px;">🔧 Edit</button></p>
</div>
</footer>
@@ -134,5 +132,6 @@
<!-- Insertr JavaScript Library -->
<script type="text/javascript" src="insertr.js"></script>
</body>
</html>
</body></html>

View File

@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Acme Consulting Services - Live Reload Test</title>
<link rel="stylesheet" href="assets/style.css">
<link rel="stylesheet" href="assets/style.css"/>
</head>
<body>
<!-- Navigation -->
<nav class="navbar">
<div class="container">
<h1 class="logo insertr">Acme Consulting</h1>
<h1 class="logo insertr" >Acme Consulting!!!</h1>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="about.html">About</a></li>
@@ -24,9 +22,9 @@
<!-- Hero Section -->
<section class="hero">
<div class="container">
<h1 class="insertr">Transform Your Business with Expert Consulting</h1>
<p class="lead insertr">We help small businesses grow through strategic planning, process optimization, and digital transformation. Our team brings 15+ years of experience to drive your success.</p>
<a href="contact.html" class="btn-primary insertr">Get Started Today</a>
<h1 class="insertr" >!Transform Your Business with Expert Consulting!</h1>
<p class="lead insertr" >We help small businesses grow through strategic planning, process optimization, and digital transformation. Our team brings 15+ years of experience to drive your success. Superb</p>
<a href="contact.html" class="btn-primary insertr" >Get Started Today?</a>
</div>
</section>
@@ -47,7 +45,7 @@
</div>
<div class="service-card">
<h3 class="insertr" >Digital Transformation</h3>
<p class="insertr">Modernize your technology stack and digital presence to compete effectively in today's marketplace.</p>
<p class="insertr" >Modernize your technology stack and digital presence to compete effectively in today&#39;s marketplace.</p>
</div>
</div>
</div>
@@ -57,7 +55,7 @@
<section class="testimonial">
<div class="container">
<blockquote>
<p class="insertr">"Acme Consulting transformed our operations completely. We saw a 40% increase in efficiency within 6 months of implementing their recommendations."</p>
<p class="insertr" >&#34;Acme Consulting transformed our operations completely. We saw a 40% increase in efficiency within 6 months of implementing their recommendations.&#34;</p>
<cite class="insertr" >Sarah Johnson, CEO of TechStart Inc.</cite>
</blockquote>
</div>
@@ -75,7 +73,7 @@
<!-- Footer -->
<footer class="footer">
<div class="container">
<p class="insertr">&copy; 2024 Acme Consulting Services. All rights reserved.</p>
<p class="insertr" >© 2024 Acme Consulting Services. All rights reserved.</p>
<p class="insertr" >📧 info@acmeconsulting.com | 📞 (555) 123-4567 | <a href="#" class="insertr-gate">Editor</a></p>
</div>
</footer>
@@ -83,5 +81,5 @@
<!-- Insertr JavaScript Library -->
<script type="text/javascript" src="insertr.js"></script>
</body>
</html>
</body></html>

View File

@@ -17,6 +17,8 @@ import (
"github.com/insertr/insertr/internal/db"
"github.com/insertr/insertr/internal/db/postgresql"
"github.com/insertr/insertr/internal/db/sqlite"
"github.com/insertr/insertr/internal/parser"
"golang.org/x/net/html"
)
// ContentHandler handles all content-related HTTP requests
@@ -232,6 +234,16 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
siteID = "default" // final fallback
}
// Determine content ID - use provided ID or generate from element context
contentID := req.ID
if contentID == "" {
if req.ElementContext == nil {
http.Error(w, "Either ID or element_context required", http.StatusBadRequest)
return
}
contentID = h.generateContentID(req.ElementContext)
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
@@ -246,7 +258,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
switch h.database.GetDBType() {
case "sqlite3":
content, err = h.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
ID: req.ID,
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
@@ -254,7 +266,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
})
case "postgresql":
content, err = h.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
ID: req.ID,
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
@@ -728,3 +740,41 @@ func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID s
}
return false
}
// generateContentID creates a content ID from element context using the parser
func (h *ContentHandler) generateContentID(ctx *ElementContext) string {
// Create virtual node for existing parser ID generation
virtualNode := &html.Node{
Type: html.ElementNode,
Data: ctx.Tag,
Attr: []html.Attribute{
{Key: "class", Val: strings.Join(ctx.Classes, " ")},
},
}
// Add parent context as a virtual parent node if provided
if ctx.ParentContext != "" && ctx.ParentContext != "content" {
parentNode := &html.Node{
Type: html.ElementNode,
Data: "section",
Attr: []html.Attribute{
{Key: "class", Val: ctx.ParentContext},
},
}
parentNode.AppendChild(virtualNode)
virtualNode.Parent = parentNode
}
// Add text content for hash generation
if ctx.OriginalContent != "" {
textNode := &html.Node{
Type: html.TextNode,
Data: ctx.OriginalContent,
}
virtualNode.AppendChild(textNode)
}
// Use existing parser ID generator
idGenerator := parser.NewIDGenerator()
return idGenerator.Generate(virtualNode)
}

View File

@@ -31,9 +31,19 @@ type ContentVersionsResponse struct {
Versions []ContentVersion `json:"versions"`
}
// Element context for backend ID generation
type ElementContext struct {
Tag string `json:"tag"`
Classes []string `json:"classes"`
OriginalContent string `json:"original_content"`
ParentContext string `json:"parent_context"`
Purpose string `json:"purpose"`
}
// Request models
type CreateContentRequest struct {
ID string `json:"id"`
ID string `json:"id,omitempty"` // For enhanced sites
ElementContext *ElementContext `json:"element_context,omitempty"` // For non-enhanced sites
SiteID string `json:"site_id,omitempty"`
Value string `json:"value"`
Type string `json:"type"`

View File

@@ -98,52 +98,32 @@ func (e *Enhancer) injectElementContent(doc *html.Node, elem parser.Element) err
return nil
}
// findAndInjectNodes recursively finds nodes and injects content
func (e *Enhancer) findAndInjectNodes(node *html.Node, elem parser.Element, contentItem *ContentItem) {
if node.Type == html.ElementNode {
// Check if this node matches our element criteria
classes := getClasses(node)
if containsClass(classes, "insertr") && node.Data == elem.Tag {
// This might be our target node - inject content
e.injector.addContentAttributes(node, elem.ContentID, string(elem.Type))
// findAndInjectNodes finds the specific node for this element and injects content
func (e *Enhancer) findAndInjectNodes(rootNode *html.Node, elem parser.Element, contentItem *ContentItem) {
// Use parser-based element matching to find the correct specific node
targetNode := e.findNodeInDocument(rootNode, elem)
if targetNode == nil {
// Element not found - this is normal for elements without content in database
return
}
// Inject content attributes for the correctly matched node
e.injector.addContentAttributes(targetNode, elem.ContentID, string(elem.Type))
// Inject content if available
if contentItem != nil {
switch elem.Type {
case parser.ContentText:
e.injector.injectTextContent(node, contentItem.Value)
e.injector.injectTextContent(targetNode, contentItem.Value)
case parser.ContentMarkdown:
e.injector.injectMarkdownContent(node, contentItem.Value)
e.injector.injectMarkdownContent(targetNode, contentItem.Value)
case parser.ContentLink:
e.injector.injectLinkContent(node, contentItem.Value)
}
e.injector.injectLinkContent(targetNode, contentItem.Value)
}
}
}
// Recursively process children
for child := node.FirstChild; child != nil; child = child.NextSibling {
e.findAndInjectNodes(child, elem, contentItem)
}
}
// Helper functions from parser package
func getClasses(node *html.Node) []string {
for _, attr := range node.Attr {
if attr.Key == "class" {
return strings.Fields(attr.Val)
}
}
return []string{}
}
func containsClass(classes []string, target string) bool {
for _, class := range classes {
if class == target {
return true
}
}
return false
}
// Helper functions are now provided by the parser package
// EnhanceDirectory processes all HTML files in a directory
func (e *Enhancer) EnhanceDirectory(inputDir, outputDir string) error {
@@ -295,28 +275,10 @@ func (e *Enhancer) enhanceFileInPlace(filePath string, elements []parser.Element
return e.writeHTML(doc, filePath)
}
// findNodeInDocument finds a specific node in the HTML document tree
// findNodeInDocument finds a specific node in the HTML document tree using parser utilities
func (e *Enhancer) findNodeInDocument(doc *html.Node, elem parser.Element) *html.Node {
// This is a simplified approach - in a production system we might need
// more sophisticated node matching based on attributes, position, etc.
return e.findNodeByTagAndClass(doc, elem.Tag, "insertr")
// Use parser's sophisticated matching logic
return parser.FindElementInDocument(doc, elem)
}
// findNodeByTagAndClass recursively searches for a node with specific tag and class
func (e *Enhancer) findNodeByTagAndClass(node *html.Node, targetTag, targetClass string) *html.Node {
if node.Type == html.ElementNode && node.Data == targetTag {
classes := getClasses(node)
if containsClass(classes, targetClass) {
return node
}
}
// Search children
for child := node.FirstChild; child != nil; child = child.NextSibling {
if result := e.findNodeByTagAndClass(child, targetTag, targetClass); result != nil {
return result
}
}
return nil
}
// All element matching functions are now provided by the parser package

View File

@@ -28,7 +28,9 @@ func (g *IDGenerator) Generate(node *html.Node) string {
contentHash := g.getContentHash(node)
baseID := g.createBaseID(context, purpose, contentHash)
return g.ensureUnique(baseID)
finalID := g.ensureUnique(baseID)
return finalID
}
// getSemanticContext determines the semantic context from parent elements
@@ -36,11 +38,11 @@ func (g *IDGenerator) getSemanticContext(node *html.Node) string {
// Walk up the tree to find semantic containers
parent := node.Parent
for parent != nil && parent.Type == html.ElementNode {
classes := getClasses(parent)
classes := GetClasses(parent)
// Check for common semantic section classes
for _, class := range []string{"hero", "services", "nav", "navbar", "footer", "about", "contact", "testimonial"} {
if containsClass(classes, class) {
if ContainsClass(classes, class) {
return class
}
}
@@ -68,7 +70,7 @@ func (g *IDGenerator) getSemanticContext(node *html.Node) string {
// getPurpose determines the purpose/role of the element
func (g *IDGenerator) getPurpose(node *html.Node) string {
tag := strings.ToLower(node.Data)
classes := getClasses(node)
classes := GetClasses(node)
// Check for specific CSS classes that indicate purpose
for _, class := range classes {

View File

@@ -90,15 +90,15 @@ func (p *Parser) parseFile(filePath string) ([]Element, []string, error) {
// findInsertrElements recursively finds all elements with "insertr" class
func (p *Parser) findInsertrElements(node *html.Node, filePath string, elements *[]Element, warnings *[]string) {
if node.Type == html.ElementNode {
classes := getClasses(node)
classes := GetClasses(node)
// Check if element has "insertr" class
if containsClass(classes, "insertr") {
if ContainsClass(classes, "insertr") {
if isContainer(node) {
// Container element - expand to viable children
viableChildren := findViableChildren(node)
for _, child := range viableChildren {
childClasses := getClasses(child)
childClasses := GetClasses(child)
element, warning := p.createElement(child, filePath, childClasses)
*elements = append(*elements, element)
if warning != "" {
@@ -181,13 +181,13 @@ func (p *Parser) resolveContentID(node *html.Node) (string, bool) {
// detectContentType determines the content type based on element and classes
func (p *Parser) detectContentType(node *html.Node, classes []string) ContentType {
// Check for explicit type classes first
if containsClass(classes, "insertr-markdown") {
if ContainsClass(classes, "insertr-markdown") {
return ContentMarkdown
}
if containsClass(classes, "insertr-link") {
if ContainsClass(classes, "insertr-link") {
return ContentLink
}
if containsClass(classes, "insertr-text") {
if ContainsClass(classes, "insertr-text") {
return ContentText
}

View File

@@ -6,8 +6,8 @@ import (
"golang.org/x/net/html"
)
// getClasses extracts CSS classes from an HTML node
func getClasses(node *html.Node) []string {
// GetClasses extracts CSS classes from an HTML node
func GetClasses(node *html.Node) []string {
classAttr := getAttribute(node, "class")
if classAttr == "" {
return []string{}
@@ -17,8 +17,8 @@ func getClasses(node *html.Node) []string {
return classes
}
// containsClass checks if a class list contains a specific class
func containsClass(classes []string, target string) bool {
// ContainsClass checks if a class list contains a specific class
func ContainsClass(classes []string, target string) bool {
for _, class := range classes {
if class == target {
return true
@@ -157,3 +157,39 @@ func isSelfClosing(node *html.Node) bool {
return selfClosingTags[node.Data]
}
// FindElementInDocument finds a parser element in HTML document tree using semantic matching
func FindElementInDocument(doc *html.Node, element Element) *html.Node {
return findElementWithContext(doc, element)
}
// findElementWithContext uses the parser's semantic understanding to find the correct element
func findElementWithContext(node *html.Node, target Element) *html.Node {
if node.Type == html.ElementNode && node.Data == target.Tag {
classes := GetClasses(node)
if ContainsClass(classes, "insertr") {
// Content-based validation for precise matching
textContent := extractTextContent(node)
nodeContent := strings.TrimSpace(textContent)
targetContent := strings.TrimSpace(target.Content)
if nodeContent == targetContent {
return node
}
}
}
// Recursively search children
for child := node.FirstChild; child != nil; child = child.NextSibling {
if result := findElementWithContext(child, target); result != nil {
return result
}
}
return nil
}
// GetAttribute gets an attribute value from an HTML node (exported version)
func GetAttribute(node *html.Node, key string) string {
return getAttribute(node, key)
}

View File

@@ -58,27 +58,39 @@ export class ApiClient {
}
}
async createContent(contentId, content, type) {
async createContent(contentId, content, type, elementContext = null) {
try {
const payload = {
value: content,
type: type
};
if (contentId) {
// Enhanced site - provide existing ID
payload.id = contentId;
} else if (elementContext) {
// Non-enhanced site - provide context for backend ID generation
payload.element_context = elementContext;
} else {
throw new Error('Either contentId or elementContext must be provided');
}
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
id: contentId,
value: content,
type: type
})
body: JSON.stringify(payload)
});
if (response.ok) {
console.log(`✅ Content created: ${contentId} (${type})`);
return true;
const result = await response.json();
console.log(`✅ Content created: ${result.id} (${result.type})`);
return result;
} else {
console.warn(`⚠️ Create failed (${response.status}): ${contentId}`);
return false;
console.warn(`⚠️ Create failed (${response.status}): ${contentId || 'backend-generated'}`);
return null;
}
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {

View File

@@ -102,31 +102,34 @@ export class InsertrEditor {
contentValue = formData.text || formData;
}
// Try to update existing content first
if (meta.hasExistingId) {
// Enhanced site - update existing content
const updateSuccess = await this.apiClient.updateContent(meta.contentId, contentValue);
if (!updateSuccess) {
// If update fails, try to create new content
console.error('❌ Failed to update content:', meta.contentId);
} else {
console.log(`✅ Content updated:`, meta.contentId, contentValue);
}
} else {
// Non-enhanced site - create with backend ID generation
const contentType = this.determineContentType(meta.element);
const createSuccess = await this.apiClient.createContent(meta.contentId, contentValue, contentType);
const result = await this.apiClient.createContent(null, contentValue, contentType, meta.elementContext);
if (!createSuccess) {
console.error('❌ Failed to save content to server:', meta.contentId);
// Still update the UI optimistically
if (result) {
// Store the backend-generated ID in the element
meta.element.setAttribute('data-content-id', result.id);
meta.element.setAttribute('data-content-type', result.type);
console.log(`✅ Content created with backend ID: ${result.id}`, contentValue);
} else {
console.error('❌ Failed to save content to server');
}
}
// Close form
this.formRenderer.closeForm();
console.log(`✅ Content saved:`, meta.contentId, contentValue);
} catch (error) {
console.error('❌ Error saving content:', error);
this.formRenderer.closeForm();
}
}

View File

@@ -104,10 +104,36 @@ export class InsertrCore {
// Get element metadata
getElementMetadata(element) {
const existingId = element.getAttribute('data-content-id');
if (existingId) {
// Enhanced site - use existing ID
return {
contentId: element.getAttribute('data-content-id') || this.generateDeterministicId(element),
contentId: existingId,
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
element: element
element: element,
hasExistingId: true
};
} else {
// Non-enhanced site - prepare context for backend ID generation
return {
contentId: null, // Backend will generate
contentType: this.detectContentType(element),
element: element,
elementContext: this.extractElementContext(element),
hasExistingId: false
};
}
}
// Extract element context for backend ID generation
extractElementContext(element) {
return {
tag: element.tagName.toLowerCase(),
classes: Array.from(element.classList),
original_content: element.textContent.trim(),
parent_context: this.getSemanticContext(element),
purpose: this.getPurpose(element)
};
}