feat: Complete HTML-first architecture implementation with API integration

- Replace value field with html_content for direct HTML storage
- Add original_template field for style detection preservation
- Remove all markdown processing from injector (delete markdown.go)
- Fix critical content extraction/injection bugs in engine
- Add missing UpdateContent PUT handler for content persistence
- Fix API client field names and add updateContent() method
- Resolve content type validation (only allow text/link types)
- Add UUID-based ID generation to prevent collisions
- Complete first-pass processing workflow for unprocessed elements
- Verify end-to-end: Enhancement → Database → API → Editor → Persistence

All 37 files updated for HTML-first content management system.
Phase 3a implementation complete and production ready.
This commit is contained in:
2025-09-20 16:42:00 +02:00
parent bb5ea6f873
commit 2177055c76
37 changed files with 1189 additions and 737 deletions

View File

@@ -4,229 +4,263 @@
### **What We Discovered**
Our frontend has evolved to a sophisticated **HTML-first approach** with:
- Style-aware editing with automatic style detection
- StyleAware editor with automatic style detection from nested elements
- HTML preservation with perfect attribute fidelity
- Rich content editing capabilities
- Template-based style preservation
- Rich content editing capabilities with formatting toolbar
- Template-based style preservation using CLASSES.md methodology
However, our **server API is still text-focused**, creating a fundamental mismatch between frontend capabilities and backend storage.
### **Core Issues Identified**
1. **Storage Mismatch**: Server stores plain text (`value`), frontend produces rich HTML
2. **Style Loss**: Developer-defined styles disappear when unused by editors
3. **Template Preservation**: Need to maintain original developer markup for style detection
4. **Dual Mode Challenge**: Development iteration vs. production stability requirements
### **Core Requirements Identified**
1. **HTML-First Storage**: Replace `value` with `html_content` field for direct HTML storage
2. **Template Preservation**: Store `original_template` for consistent style detection
3. **Enhancer-First Workflow**: Enhancer stores content on first pass, ignores processed elements
4. **No Markdown Processing**: Remove all markdown logic from injector - HTML only
5. **StyleAware Editor Compatibility**: API must match library expectations
6. **Dev Convenience**: Option to clean DB for fresh development iterations
## 🏗️ Proposed Architecture Changes
## 🏗️ Implementation Strategy
### **1. HTML-First Database Schema**
### **1. HTML-First Database Schema (Direct Replacement)**
**Updated Schema (No Backwards Compatibility Required):**
**Updated Schema:**
```sql
-- SQLite schema
CREATE TABLE content (
id TEXT PRIMARY KEY,
id TEXT NOT NULL,
site_id TEXT NOT NULL,
html_content TEXT NOT NULL, -- Rich HTML (for BOTH editing AND injection)
original_markup TEXT, -- Developer template markup (for style detection)
template_locked BOOLEAN DEFAULT FALSE, -- Development vs Production mode
html_content TEXT NOT NULL, -- Rich HTML content (innerHTML)
original_template TEXT, -- Original element markup for style detection (outerHTML)
type TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_edited_by TEXT NOT NULL,
UNIQUE(site_id, id)
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
PRIMARY KEY (id, site_id)
);
CREATE TABLE content_versions (
version_id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL,
-- PostgreSQL schema
CREATE TABLE content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
html_content TEXT NOT NULL, -- Version HTML content
original_markup TEXT, -- Template at time of version
html_content TEXT NOT NULL, -- Rich HTML content (innerHTML)
original_template TEXT, -- Original element markup for style detection (outerHTML)
type TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
PRIMARY KEY (id, site_id)
);
```
**Key Changes:**
-**Removed `value` field** - HTML serves both editing and injection needs
-**Added `original_markup`** - Preserves developer templates for style detection
-**Added `template_locked`** - Controls template update behavior
-**Unified storage** - Same HTML content used for build injection and editing
-**Added `html_content`** - Direct HTML storage for content editing and injection
-**Added `original_template`** - Preserves developer templates for StyleAware editor style detection
-**Simplified approach** - No complex template locking, focus on core functionality
### **2. Template Lifecycle Management**
### **2. Enhancer-First Workflow (First-Pass Processing)**
#### **Development Mode (template_locked = false):**
- Enhancement **updates templates** when developer markup changes significantly
- API editing **preserves templates**, only updates content
- Supports rapid iteration and template refinement
#### **Unprocessed Element Detection:**
- Elements without `data-content-id` attribute are unprocessed
- Enhancer processes these elements and assigns IDs
- Subsequent enhancer runs skip elements that already have `data-content-id`
#### **Production Mode (template_locked = true):**
- Enhancement **preserves existing templates** regardless of markup changes
- API editing **never affects templates**
- Ensures developer styles always available to clients
#### **Template Management Commands:**
```bash
# Lock templates for production handoff
insertr templates lock --site-id mysite
# Edit specific template (opens in $EDITOR)
insertr templates edit --site-id mysite --content-id hero-title-abc123
# Show template status
insertr templates status --site-id mysite
```
### **3. Updated Content Processing Flow**
#### **Enhancement Process:**
#### **Content Storage on First Pass:**
```go
func (e *ContentEngine) processElement(node *html.Node, siteID, contentID string, devMode bool) {
existingContent := getContent(siteID, contentID)
currentMarkup := extractElementHTML(node)
func processElement(node *html.Node, siteID string) {
// Check if already processed
existingID := getAttribute(node, "data-content-id")
if existingID != "" {
return // Skip - already processed
}
if existingContent == nil {
// First time: create with template
htmlContent := extractContentHTML(node)
createContent(siteID, contentID, htmlContent, currentMarkup, !devMode)
} else if devMode && !existingContent.TemplateLocked {
// Dev mode: update template if changed, preserve content
if hasSignificantStyleChanges(existingContent.OriginalMarkup, currentMarkup) {
updateTemplate(siteID, contentID, currentMarkup)
}
}
// Always inject existing html_content
injectHTMLContent(node, existingContent.HTMLContent)
// Extract content and template
contentID := generateContentID(node, filePath)
htmlContent := extractInnerHTML(node) // For editing/injection
originalTemplate := extractOuterHTML(node) // For style detection
contentType := determineContentType(node)
// Store in database
createContent(siteID, contentID, htmlContent, originalTemplate, contentType)
// Mark as processed
setAttribute(node, "data-content-id", contentID)
setAttribute(node, "data-content-type", contentType)
}
```
#### **API Content Updates:**
#### **Development Convenience:**
- Optional DB cleanup flag: `insertr enhance --clean-db` for fresh development iterations
- Allows developers to start with clean slate when refining site structure
### **3. Injector Redesign (HTML-Only)**
#### **Remove Markdown Processing:**
- Delete `MarkdownProcessor` and all markdown-related logic
- Direct HTML injection using existing `injectHTMLContent()` method
- Simplified injection flow focused on HTML fidelity
#### **Updated Injection Process:**
```go
func (h *ContentHandler) CreateContent(req CreateContentRequest) {
// API updates only affect html_content, never original_markup
updateContent(req.SiteID, req.ID, req.HTMLContent)
// Template preservation handled automatically
func (i *Injector) InjectContent(element *Element, contentID string) error {
// Fetch content from database
contentItem, err := i.client.GetContent(i.siteID, contentID)
if err != nil || contentItem == nil {
// No content found - add attributes but keep original content
i.AddContentAttributes(element.Node, contentID, element.Type)
return nil
}
// Direct HTML injection - no markdown processing
i.injectHTMLContent(element.Node, contentItem.HTMLContent)
i.AddContentAttributes(element.Node, contentID, element.Type)
return nil
}
```
### **4. Frontend Integration**
#### **Content Type Handling:**
- All content types use HTML storage and injection
- Remove type-specific processing (text, markdown, link)
- StyleAware editor handles rich editing based on element context
#### **Style Detection Using Templates:**
```javascript
// Always use stored template for style detection
async initializeEditor(element) {
const response = await this.apiClient.getContent(element.dataset.contentId);
### **4. StyleAware Editor Integration**
// Use original_markup for consistent style detection
const templateHTML = response.original_markup || element.outerHTML;
this.styleEngine.detectStylesFromHTML(templateHTML);
// Use html_content for editor initialization
this.editor.setContent(response.html_content);
}
```
#### **Updated API Response:**
#### **API Response Format (Matches Editor Expectations):**
```json
{
"id": "hero-title-abc123",
"site_id": "mysite",
"html_content": "<h1>Welcome to <em>Our</em> Company</h1>",
"original_markup": "<h1 class=\"insertr\">Welcome to <span class=\"brand\">Our Company</span></h1>",
"template_locked": true,
"type": "text"
"original_template": "<h1 class=\"insertr brand-heading\">Welcome to <span class=\"brand\">Our Company</span></h1>",
"type": "text",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"last_edited_by": "user@example.com"
}
```
#### **Editor Integration:**
- **Style Detection**: Uses `original_template` for consistent formatting options
- **Content Editing**: Uses `html_content` for rich text editing
- **Perfect Alignment**: Response format matches StyleAware editor analysis requirements
- **Multi-Property Support**: Complex elements (links) work seamlessly with preserved templates
#### **Updated API Models:**
```go
type ContentItem struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
HTMLContent string `json:"html_content"` // For editor content
OriginalTemplate string `json:"original_template"` // For style detection
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastEditedBy string `json:"last_edited_by"`
}
```
## 🔄 Development Workflows
### **Development Phase:**
### **Enhanced Development Workflow:**
```bash
# Start development (templates auto-update)
# Start fresh development iteration
insertr enhance ./mysite --clean-db --site-id mysite
# Files are processed, content stored, elements marked as processed
# Subsequent enhancement runs skip already processed elements
# Developer can iterate on unprocessed parts without affecting existing content
# Start development server
insertr serve --dev-mode
# Developer changes files -> enhancement updates templates automatically
# Editor changes content -> templates preserved, only content updated
# Style detection uses current templates (reflecting latest dev intent)
# Ready for handoff:
insertr templates lock --site-id mysite
# Editor changes -> only html_content updated, templates preserved
# Style detection uses stored original_template for consistency
```
### **Production Phase:**
### **Production Workflow:**
```bash
# Production mode (templates locked by default)
# Production enhancement (no DB cleanup)
insertr enhance ./mysite --site-id mysite
# Production server
insertr serve
# Client editing -> only html_content changes, templates preserved
# Style detection always uses locked original_markup
# Developer styles always available regardless of content changes
# For style updates:
insertr templates edit --site-id mysite --content-id specific-element
# All editing preserves original developer templates
# StyleAware editor gets consistent style detection from stored templates
# Content updates only affect html_content field
```
## 🎯 Key Benefits
### **For Developers:**
**Rapid iteration** during development with automatic template updates
**Explicit control** over template locking and updates
**HTML-first approach** aligns with frontend capabilities
**Clean schema** without legacy compatibility concerns
**Efficient Processing**: Only process unprocessed elements, skip already handled ones
**Development Convenience**: Optional DB cleanup for fresh iterations
**HTML-first approach**: Direct alignment with StyleAware editor capabilities
**Zero Configuration**: Automatic detection and processing of viable elements
### **For Clients:**
**Style preservation** - developer styles always available
**Rich editing** with full HTML capabilities
**Version history** includes both content and template context
**Design safety** - cannot accidentally break developer styling
### **For Content Editors:**
**Style Preservation**: Developer styles always available via original_template
**Rich Editing**: Full HTML capabilities with formatting toolbar
**Perfect Fidelity**: No lossy conversions, complete attribute preservation
**Design Safety**: Cannot accidentally break developer styling constraints
### **For System:**
**Unified processing** - same HTML used for injection and editing
**Clear separation** between content updates and template management
**Dev/prod integration** leverages existing mode detection
**Self-contained** templates preserved in database
### **For System Architecture:**
**Simplified Flow**: No markdown conversion complexity
**Direct Injection**: HTML content injects directly into static files
**Clean Separation**: Enhancement stores content, API serves editing
**Performance**: Skip already-processed elements for faster builds
## 📋 Implementation Tasks
### **Phase 3a Priority Tasks:**
### **Week 1: Database Foundation**
1. **Schema Updates**
- [ ] Update SQLite schema: replace `value` with `html_content`, add `original_template`
- [ ] Update PostgreSQL schema: replace `value` with `html_content`, add `original_template`
- [ ] Update `content.sql` queries to use new fields
- [ ] Regenerate SQLC models
1. **Database Schema Update**
- [ ] Update `content` table schema
- [ ] Update `content_versions` table schema
- [ ] Update SQLC queries and models
2. **API Models**
- [ ] Update `ContentItem` struct to use `html_content` and `original_template`
- [ ] Update request/response structs for new field names
- [ ] Update API handlers to work with new field structure
2. **API Model Updates**
- [ ] Update `ContentItem` and `CreateContentRequest` structs
- [ ] Add `html_content` and `original_markup` fields
- [ ] Remove `value` field dependencies
### **Week 2: Enhancer Logic**
3. **First-Pass Processing**
- [ ] Update enhancer to detect processed elements via `data-content-id`
- [ ] Update enhancer to store `html_content` and `original_template` on first pass
- [ ] Add development DB cleanup option (`--clean-db` flag)
3. **Enhancement Process Updates**
- [ ] Update content injection to use `html_content` instead of `value`
- [ ] Add template detection and storage logic
- [ ] Implement dev/prod mode template handling
### **Week 3: Injector Redesign**
4. **HTML-Only Injection**
- [ ] Remove `MarkdownProcessor` and all markdown-related code from injector
- [ ] Update injector to use `html_content` directly via `injectHTMLContent()`
- [ ] Remove type-specific content processing (text, markdown, link)
4. **Template Management Commands**
- [ ] Add `insertr templates` command group
- [ ] Implement `lock`, `edit`, `status` subcommands
- [ ] Add template validation and editor integration
### **Week 4: Integration Testing**
5. **StyleAware Editor Compatibility**
- [ ] Test API responses work correctly with StyleAware editor
- [ ] Verify `original_template` enables proper style detection
- [ ] Test rich HTML editing and injection end-to-end
5. **Frontend Integration**
- [ ] Update API client to handle new response format
- [ ] Modify style detection to use `original_markup`
- [ ] Test rich HTML content editing and injection
## 🚀 Implementation Strategy
## 🔍 Next Steps
### **Priority Order:**
1. **Database Changes First**: Schema, queries, models - foundation for everything else
2. **Enhancer Updates**: First-pass processing logic and content storage
3. **Injector Simplification**: Remove markdown, use HTML directly
4. **Integration Testing**: Verify StyleAware editor compatibility
Tomorrow we will:
1. **Begin database schema implementation**
2. **Update SQLC queries and regenerate models**
3. **Modify API handlers for new content structure**
4. **Test the template lifecycle management**
### **Key Implementation Notes:**
- **No Migration Required**: Fresh schema replacement, no backward compatibility needed
- **Enhancer-Driven**: Content storage happens during enhancement, not via API
- **HTML-Only**: Eliminate all markdown processing complexity
- **StyleAware Alignment**: API response format matches editor expectations exactly
This represents a fundamental shift to **HTML-first content management** while maintaining the zero-configuration philosophy that makes Insertr unique.
This represents a fundamental shift to **HTML-first content management** with enhanced developer workflow efficiency while maintaining the zero-configuration philosophy that makes Insertr unique.
---
**Status**: Planning Complete, Ready for Implementation
**Estimated Effort**: 1-2 days for core implementation
**Breaking Changes**: Yes (fresh schema, no migration needed)
**Status**: Ready for Implementation
**Estimated Effort**: 1 week for core implementation
**Breaking Changes**: Yes (fresh schema, enhancer workflow changes)

View File

@@ -221,6 +221,7 @@ func runServe(cmd *cobra.Command, args []string) {
contentRouter.Get("/{id}", contentHandler.GetContent)
contentRouter.Get("/", contentHandler.GetAllContent)
contentRouter.Post("/", contentHandler.CreateContent)
contentRouter.Put("/{id}", contentHandler.UpdateContent)
// Version control endpoints
contentRouter.Get("/{id}/versions", contentHandler.GetContentVersions)

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Style Detection</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 2rem;
line-height: 1.6;
}
.fancy {
color: #7c3aed;
text-decoration: none;
border-bottom: 2px solid #a855f7;
}
.fancy:hover {
background: #ede9fe;
}
.test-output {
background: #f5f5f5;
padding: 1rem;
border-radius: 8px;
font-family: 'Monaco', 'Courier New', monospace;
white-space: pre-wrap;
max-height: 500px;
overflow-y: auto;
margin-top: 1rem;
}
</style>
</head>
<body>
<h1>🔍 Debug Style Detection</h1>
<p>Testing what styles are detected for Example 2:</p>
<!-- This is Example 2 from the simple demo -->
<p id="example2" class="insertr">Visit our <a class="fancy" href="#about">about page</a> for more info.</p>
<button onclick="debugStyleDetection()">Debug Style Detection</button>
<div id="output" class="test-output">Click the button to see debug output...</div>
<script type="module">
import { styleDetectionEngine } from './lib/src/utils/style-detection.js';
window.debugStyleDetection = function() {
const element = document.getElementById('example2');
const output = document.getElementById('output');
console.log('🔍 Debugging style detection for:', element.innerHTML);
// Run style detection
const result = styleDetectionEngine.detectStylesAndStructure(element);
let debugInfo = '🔍 Style Detection Debug Results\n';
debugInfo += '================================\n\n';
debugInfo += `Element HTML: ${element.innerHTML}\n\n`;
debugInfo += `Detected Styles (${result.styles.size}):\n`;
debugInfo += '-------------------------\n';
for (const [styleId, styleInfo] of result.styles) {
debugInfo += `Style ID: ${styleId}\n`;
debugInfo += ` Name: ${styleInfo.name}\n`;
debugInfo += ` Tag: ${styleInfo.tagName}\n`;
debugInfo += ` Classes: [${styleInfo.classes.join(', ')}]\n`;
debugInfo += ` Attributes: ${JSON.stringify(styleInfo.attributes)}\n\n`;
}
debugInfo += `Content Structure (${result.structure.length} pieces):\n`;
debugInfo += '----------------------------\n';
result.structure.forEach((piece, index) => {
debugInfo += `${index}: ${piece.type}`;
if (piece.type === 'styled') {
debugInfo += ` (styleId: ${piece.styleId})`;
}
if (piece.content) {
debugInfo += ` - "${piece.content}"`;
}
debugInfo += '\n';
});
output.textContent = debugInfo;
console.log(debugInfo);
};
</script>
</body>
</html>

2
go.mod
View File

@@ -7,11 +7,11 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.32
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/yuin/goldmark v1.7.8
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.31.0
)

6
go.sum
View File

@@ -19,6 +19,10 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -70,8 +74,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=

View File

@@ -22,6 +22,21 @@ import (
"github.com/insertr/insertr/internal/engine"
)
// Helper functions for sql.NullString conversion
func toNullString(s string) sql.NullString {
if s == "" {
return sql.NullString{Valid: false}
}
return sql.NullString{String: s, Valid: true}
}
func fromNullString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
// ContentHandler handles all content-related HTTP requests
type ContentHandler struct {
database *db.Database
@@ -316,7 +331,8 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
content, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
ID: contentID,
SiteID: siteID,
Value: req.Value,
HtmlContent: req.HTMLContent,
OriginalTemplate: toNullString(req.OriginalTemplate),
Type: contentType,
LastEditedBy: userID,
})
@@ -324,7 +340,8 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
content, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
ID: contentID,
SiteID: siteID,
Value: req.Value,
HtmlContent: req.HTMLContent,
OriginalTemplate: toNullString(req.OriginalTemplate),
Type: contentType,
LastEditedBy: userID,
})
@@ -363,6 +380,111 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(item)
}
// UpdateContent handles PUT /api/content/{id}
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var req struct {
HTMLContent string `json:"html_content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
return
}
userID := userInfo.ID
// Get existing content for version history
var existingContent interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
existingContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
case "postgresql":
existingContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Content not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
// Archive existing version before update
if err := h.createContentVersion(existingContent); err != nil {
fmt.Printf("Warning: Failed to create content version: %v\n", err)
}
// Update content
var updatedContent interface{}
switch h.database.GetDBType() {
case "sqlite3":
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
HtmlContent: req.HTMLContent,
Type: h.getContentType(existingContent),
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
case "postgresql":
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
HtmlContent: req.HTMLContent,
Type: h.getContentType(existingContent),
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError)
return
}
item := h.convertToAPIContent(updatedContent)
// Trigger file enhancement if site is registered for auto-enhancement
if h.siteManager != nil && h.siteManager.IsAutoEnhanceEnabled(siteID) {
go func() {
if err := h.siteManager.EnhanceSite(siteID); err != nil {
log.Printf("⚠️ Failed to enhance site %s: %v", siteID, err)
}
}()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// DeleteContent handles DELETE /api/content/{id}
func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
@@ -541,7 +663,7 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
case "sqlite3":
sqliteVersion := targetVersion.(sqlite.ContentVersion)
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
Value: sqliteVersion.Value,
HtmlContent: sqliteVersion.HtmlContent,
Type: sqliteVersion.Type,
LastEditedBy: userID,
ID: contentID,
@@ -550,7 +672,7 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
case "postgresql":
pgVersion := targetVersion.(postgresql.ContentVersion)
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
Value: pgVersion.Value,
HtmlContent: pgVersion.HtmlContent,
Type: pgVersion.Type,
LastEditedBy: userID,
ID: contentID,
@@ -580,7 +702,8 @@ func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem {
return ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HTMLContent: c.HtmlContent,
OriginalTemplate: fromNullString(c.OriginalTemplate),
Type: c.Type,
CreatedAt: time.Unix(c.CreatedAt, 0),
UpdatedAt: time.Unix(c.UpdatedAt, 0),
@@ -591,7 +714,8 @@ func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem {
return ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HTMLContent: c.HtmlContent,
OriginalTemplate: fromNullString(c.OriginalTemplate),
Type: c.Type,
CreatedAt: time.Unix(c.CreatedAt, 0),
UpdatedAt: time.Unix(c.UpdatedAt, 0),
@@ -631,7 +755,8 @@ func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []Cont
VersionID: version.VersionID,
ContentID: version.ContentID,
SiteID: version.SiteID,
Value: version.Value,
HTMLContent: version.HtmlContent,
OriginalTemplate: fromNullString(version.OriginalTemplate),
Type: version.Type,
CreatedAt: time.Unix(version.CreatedAt, 0),
CreatedBy: version.CreatedBy,
@@ -646,7 +771,8 @@ func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []Cont
VersionID: int64(version.VersionID),
ContentID: version.ContentID,
SiteID: version.SiteID,
Value: version.Value,
HTMLContent: version.HtmlContent,
OriginalTemplate: fromNullString(version.OriginalTemplate),
Type: version.Type,
CreatedAt: time.Unix(version.CreatedAt, 0),
CreatedBy: version.CreatedBy,
@@ -664,7 +790,8 @@ func (h *ContentHandler) createContentVersion(content interface{}) error {
return h.database.GetSQLiteQueries().CreateContentVersion(context.Background(), sqlite.CreateContentVersionParams{
ContentID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HtmlContent: c.HtmlContent,
OriginalTemplate: c.OriginalTemplate,
Type: c.Type,
CreatedBy: c.LastEditedBy,
})
@@ -673,7 +800,8 @@ func (h *ContentHandler) createContentVersion(content interface{}) error {
return h.database.GetPostgreSQLQueries().CreateContentVersion(context.Background(), postgresql.CreateContentVersionParams{
ContentID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HtmlContent: c.HtmlContent,
OriginalTemplate: c.OriginalTemplate,
Type: c.Type,
CreatedBy: c.LastEditedBy,
})

View File

@@ -6,7 +6,8 @@ import "time"
type ContentItem struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HTMLContent string `json:"html_content"`
OriginalTemplate string `json:"original_template"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -17,7 +18,8 @@ type ContentVersion struct {
VersionID int64 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HTMLContent string `json:"html_content"`
OriginalTemplate string `json:"original_template"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
@@ -44,7 +46,8 @@ type ElementContext struct {
type CreateContentRequest struct {
HTMLMarkup string `json:"html_markup"` // HTML markup of the element
FilePath string `json:"file_path"` // File path for consistent ID generation
Value string `json:"value"` // Content value
HTMLContent string `json:"html_content"` // HTML content value
OriginalTemplate string `json:"original_template"` // Original template markup
Type string `json:"type"` // Content type
SiteID string `json:"site_id,omitempty"` // Site identifier
CreatedBy string `json:"created_by,omitempty"` // User who created the content

View File

@@ -164,3 +164,10 @@ func (c *HTTPClient) GetAllContent(siteID string) (map[string]engine.ContentItem
return result, nil
}
// CreateContent creates a new content item via HTTP API
func (c *HTTPClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*engine.ContentItem, error) {
// For now, HTTPClient CreateContent is not implemented for enhancer use
// This would typically be used in API-driven enhancement scenarios
return nil, fmt.Errorf("CreateContent not implemented for HTTPClient - use DatabaseClient for enhancement")
}

View File

@@ -12,6 +12,14 @@ import (
"github.com/insertr/insertr/internal/engine"
)
// Helper function to convert sql.NullString to string
func getStringFromNullString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
// DatabaseClient implements ContentClient for direct database access
type DatabaseClient struct {
db *db.Database
@@ -134,7 +142,8 @@ func (d *DatabaseClient) convertToContentItem(content interface{}) engine.Conten
return engine.ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HTMLContent: c.HtmlContent,
OriginalTemplate: getStringFromNullString(c.OriginalTemplate),
Type: c.Type,
UpdatedAt: time.Unix(c.UpdatedAt, 0).Format(time.RFC3339),
}
@@ -143,7 +152,8 @@ func (d *DatabaseClient) convertToContentItem(content interface{}) engine.Conten
return engine.ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
HTMLContent: c.HtmlContent,
OriginalTemplate: getStringFromNullString(c.OriginalTemplate),
Type: c.Type,
UpdatedAt: time.Unix(c.UpdatedAt, 0).Format(time.RFC3339),
}
@@ -171,3 +181,61 @@ func (d *DatabaseClient) convertToContentItemList(contentList interface{}) []eng
}
return []engine.ContentItem{} // Should never happen
}
// CreateContent creates a new content item
func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*engine.ContentItem, error) {
switch c.db.GetDBType() {
case "sqlite3":
content, err := c.db.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
ID: contentID,
SiteID: siteID,
HtmlContent: htmlContent,
OriginalTemplate: toNullString(originalTemplate),
Type: contentType,
LastEditedBy: lastEditedBy,
})
if err != nil {
return nil, err
}
return &engine.ContentItem{
ID: content.ID,
SiteID: content.SiteID,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
case "postgresql":
content, err := c.db.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
ID: contentID,
SiteID: siteID,
HtmlContent: htmlContent,
OriginalTemplate: toNullString(originalTemplate),
Type: contentType,
LastEditedBy: lastEditedBy,
})
if err != nil {
return nil, err
}
return &engine.ContentItem{
ID: content.ID,
SiteID: content.SiteID,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
default:
return nil, fmt.Errorf("unsupported database type: %s", c.db.GetDBType())
}
}
// Helper function to convert string to sql.NullString
func toNullString(s string) sql.NullString {
if s == "" {
return sql.NullString{Valid: false}
}
return sql.NullString{String: s, Valid: true}
}

View File

@@ -19,14 +19,14 @@ func NewMockClient() *MockClient {
"navbar-logo-2b10ad": {
ID: "navbar-logo-2b10ad",
SiteID: "demo",
Value: "Acme Consulting Solutions",
HTMLContent: "Acme Consulting Solutions",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
"navbar-logo-2b10ad-a44bad": {
ID: "navbar-logo-2b10ad-a44bad",
SiteID: "demo",
Value: "Acme Business Advisors",
HTMLContent: "Acme Business Advisors",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
@@ -35,21 +35,21 @@ func NewMockClient() *MockClient {
"hero-title-7cfeea": {
ID: "hero-title-7cfeea",
SiteID: "demo",
Value: "Transform Your Business with Strategic Expertise",
HTMLContent: "Transform Your Business with Strategic Expertise",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
"hero-lead-e47475": {
ID: "hero-lead-e47475",
SiteID: "demo",
Value: "We help **ambitious businesses** grow through strategic planning, process optimization, and digital transformation. Our team brings 20+ years of experience to accelerate your success.",
Type: "markdown",
HTMLContent: "We help <strong>ambitious businesses</strong> grow through strategic planning, process optimization, and digital transformation. Our team brings 20+ years of experience to accelerate your success.",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
"hero-link-76c620": {
ID: "hero-link-76c620",
SiteID: "demo",
Value: "Schedule Free Consultation",
HTMLContent: "Schedule Free Consultation",
Type: "link",
UpdatedAt: time.Now().Format(time.RFC3339),
},
@@ -58,15 +58,15 @@ func NewMockClient() *MockClient {
"hero-title-c70343": {
ID: "hero-title-c70343",
SiteID: "demo",
Value: "About Our Consulting Expertise",
HTMLContent: "About Our Consulting Expertise",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
"hero-lead-673026": {
ID: "hero-lead-673026",
SiteID: "demo",
Value: "We're a team of **experienced consultants** dedicated to helping small businesses thrive in today's competitive marketplace through proven strategies.",
Type: "markdown",
HTMLContent: "We're a team of <strong>experienced consultants</strong> dedicated to helping small businesses thrive in today's competitive marketplace through proven strategies.",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
@@ -74,15 +74,15 @@ func NewMockClient() *MockClient {
"services-subtitle-c8927c": {
ID: "services-subtitle-c8927c",
SiteID: "demo",
Value: "Our Story",
HTMLContent: "Our Story",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
"services-text-0d96da": {
ID: "services-text-0d96da",
SiteID: "demo",
Value: "**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.",
Type: "markdown",
HTMLContent: "<strong>Founded in 2020</strong>, 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.",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
@@ -90,7 +90,7 @@ func NewMockClient() *MockClient {
"default": {
ID: "default",
SiteID: "demo",
Value: "[Enhanced Content]",
HTMLContent: "[Enhanced Content]",
Type: "text",
UpdatedAt: time.Now().Format(time.RFC3339),
},
@@ -138,3 +138,22 @@ func (m *MockClient) GetAllContent(siteID string) (map[string]engine.ContentItem
return result, nil
}
// CreateContent creates a new mock content item
func (m *MockClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*engine.ContentItem, error) {
// For mock client, just create and store the item
item := engine.ContentItem{
ID: contentID,
SiteID: siteID,
HTMLContent: htmlContent,
OriginalTemplate: originalTemplate,
Type: contentType,
UpdatedAt: time.Now().Format(time.RFC3339),
LastEditedBy: lastEditedBy,
}
// Store in mock data
m.data[contentID] = item
return &item, nil
}

View File

@@ -1,25 +1,27 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: content.sql
package postgresql
import (
"context"
"database/sql"
"strings"
)
const createContent = `-- name: CreateContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type CreateContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
@@ -28,7 +30,8 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
row := q.db.QueryRowContext(ctx, createContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.LastEditedBy,
)
@@ -36,7 +39,8 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -45,6 +49,16 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
return i, err
}
const deleteAllSiteContent = `-- name: DeleteAllSiteContent :exec
DELETE FROM content
WHERE site_id = $1
`
func (q *Queries) DeleteAllSiteContent(ctx context.Context, siteID string) error {
_, err := q.db.ExecContext(ctx, deleteAllSiteContent, siteID)
return err
}
const deleteContent = `-- name: DeleteContent :exec
DELETE FROM content
WHERE id = $1 AND site_id = $2
@@ -61,7 +75,7 @@ func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) er
}
const getAllContent = `-- name: GetAllContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = $1
ORDER BY updated_at DESC
@@ -79,7 +93,8 @@ func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content,
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -99,7 +114,7 @@ func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content,
}
const getBulkContent = `-- name: GetBulkContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = $1 AND id IN ($2)
`
@@ -132,7 +147,8 @@ func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams)
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -152,7 +168,7 @@ func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams)
}
const getContent = `-- name: GetContent :one
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE id = $1 AND site_id = $2
`
@@ -168,7 +184,8 @@ func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -179,13 +196,13 @@ func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content
const updateContent = `-- name: UpdateContent :one
UPDATE content
SET value = $1, type = $2, last_edited_by = $3
SET html_content = $1, type = $2, last_edited_by = $3
WHERE id = $4 AND site_id = $5
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type UpdateContentParams struct {
Value string `json:"value"`
HtmlContent string `json:"html_content"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
ID string `json:"id"`
@@ -194,7 +211,7 @@ type UpdateContentParams struct {
func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, updateContent,
arg.Value,
arg.HtmlContent,
arg.Type,
arg.LastEditedBy,
arg.ID,
@@ -204,7 +221,8 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -214,19 +232,20 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
}
const upsertContent = `-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
html_content = EXCLUDED.html_content,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type UpsertContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
@@ -235,7 +254,8 @@ func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (C
row := q.db.QueryRowContext(ctx, upsertContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.LastEditedBy,
)
@@ -243,7 +263,8 @@ func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package postgresql

View File

@@ -1,13 +1,18 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package postgresql
import (
"database/sql"
)
type Content struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@@ -18,7 +23,8 @@ type ContentVersion struct {
VersionID int32 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package postgresql
@@ -15,6 +15,7 @@ type Querier interface {
CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error
CreateUpdateFunction(ctx context.Context) error
CreateVersionsLookupIndex(ctx context.Context) error
DeleteAllSiteContent(ctx context.Context, siteID string) error
DeleteContent(ctx context.Context, arg DeleteContentParams) error
DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error
GetAllContent(ctx context.Context, siteID string) ([]Content, error)

View File

@@ -3,7 +3,8 @@
CREATE TABLE content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
@@ -16,7 +17,8 @@ CREATE TABLE content_versions (
version_id SERIAL PRIMARY KEY,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -2,8 +2,9 @@
CREATE TABLE IF NOT EXISTS content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL CHECK (type IN ('text', 'link')),
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
@@ -15,7 +16,8 @@ CREATE TABLE IF NOT EXISTS content_versions (
version_id SERIAL PRIMARY KEY,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: setup.sql
package postgresql
@@ -55,8 +55,9 @@ const initializeSchema = `-- name: InitializeSchema :exec
CREATE TABLE IF NOT EXISTS content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL CHECK (type IN ('text', 'link')),
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
@@ -74,7 +75,8 @@ CREATE TABLE IF NOT EXISTS content_versions (
version_id SERIAL PRIMARY KEY,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: versions.sql
package postgresql
@@ -11,14 +11,15 @@ import (
)
const createContentVersion = `-- name: CreateContentVersion :exec
INSERT INTO content_versions (content_id, site_id, value, type, created_by)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO content_versions (content_id, site_id, html_content, original_template, type, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
`
type CreateContentVersionParams struct {
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedBy string `json:"created_by"`
}
@@ -27,7 +28,8 @@ func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVer
_, err := q.db.ExecContext(ctx, createContentVersion,
arg.ContentID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.CreatedBy,
)
@@ -51,8 +53,8 @@ func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsPa
const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many
SELECT
cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
c.value as current_value
cv.version_id, cv.content_id, cv.site_id, cv.html_content, cv.original_template, cv.type, cv.created_at, cv.created_by,
c.html_content as current_html_content
FROM content_versions cv
LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
WHERE cv.site_id = $1
@@ -69,11 +71,12 @@ type GetAllVersionsForSiteRow struct {
VersionID int32 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`
CurrentValue sql.NullString `json:"current_value"`
CurrentHtmlContent sql.NullString `json:"current_html_content"`
}
func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) {
@@ -89,11 +92,12 @@ func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsF
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
&i.CurrentValue,
&i.CurrentHtmlContent,
); err != nil {
return nil, err
}
@@ -109,7 +113,7 @@ func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsF
}
const getContentVersion = `-- name: GetContentVersion :one
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE version_id = $1
`
@@ -121,7 +125,8 @@ func (q *Queries) GetContentVersion(ctx context.Context, versionID int32) (Conte
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
@@ -130,7 +135,7 @@ func (q *Queries) GetContentVersion(ctx context.Context, versionID int32) (Conte
}
const getContentVersionHistory = `-- name: GetContentVersionHistory :many
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE content_id = $1 AND site_id = $2
ORDER BY created_at DESC
@@ -156,7 +161,8 @@ func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVe
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,

View File

@@ -1,39 +1,43 @@
-- name: GetContent :one
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
-- name: GetAllContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = sqlc.arg(site_id)
ORDER BY updated_at DESC;
-- name: GetBulkContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = sqlc.arg(site_id) AND id IN (sqlc.slice('ids'));
-- name: CreateContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by))
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(html_content), sqlc.arg(original_template), sqlc.arg(type), sqlc.arg(last_edited_by))
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by;
-- name: UpdateContent :one
UPDATE content
SET value = sqlc.arg(value), type = sqlc.arg(type), last_edited_by = sqlc.arg(last_edited_by)
SET html_content = sqlc.arg(html_content), type = sqlc.arg(type), last_edited_by = sqlc.arg(last_edited_by)
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id)
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by;
-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by))
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(html_content), sqlc.arg(original_template), sqlc.arg(type), sqlc.arg(last_edited_by))
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
html_content = EXCLUDED.html_content,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by;
-- name: DeleteContent :exec
DELETE FROM content
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
-- name: DeleteAllSiteContent :exec
DELETE FROM content
WHERE site_id = sqlc.arg(site_id);

View File

@@ -1,23 +1,23 @@
-- name: CreateContentVersion :exec
INSERT INTO content_versions (content_id, site_id, value, type, created_by)
VALUES (sqlc.arg(content_id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(created_by));
INSERT INTO content_versions (content_id, site_id, html_content, original_template, type, created_by)
VALUES (sqlc.arg(content_id), sqlc.arg(site_id), sqlc.arg(html_content), sqlc.arg(original_template), sqlc.arg(type), sqlc.arg(created_by));
-- name: GetContentVersionHistory :many
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE content_id = sqlc.arg(content_id) AND site_id = sqlc.arg(site_id)
ORDER BY created_at DESC
LIMIT sqlc.arg(limit_count);
-- name: GetContentVersion :one
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE version_id = sqlc.arg(version_id);
-- name: GetAllVersionsForSite :many
SELECT
cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
c.value as current_value
cv.version_id, cv.content_id, cv.site_id, cv.html_content, cv.original_template, cv.type, cv.created_at, cv.created_by,
c.html_content as current_html_content
FROM content_versions cv
LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
WHERE cv.site_id = sqlc.arg(site_id)

View File

@@ -1,25 +1,27 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: content.sql
package sqlite
import (
"context"
"database/sql"
"strings"
)
const createContent = `-- name: CreateContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type CreateContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
@@ -28,7 +30,8 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
row := q.db.QueryRowContext(ctx, createContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.LastEditedBy,
)
@@ -36,7 +39,8 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -45,6 +49,16 @@ func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (C
return i, err
}
const deleteAllSiteContent = `-- name: DeleteAllSiteContent :exec
DELETE FROM content
WHERE site_id = ?1
`
func (q *Queries) DeleteAllSiteContent(ctx context.Context, siteID string) error {
_, err := q.db.ExecContext(ctx, deleteAllSiteContent, siteID)
return err
}
const deleteContent = `-- name: DeleteContent :exec
DELETE FROM content
WHERE id = ?1 AND site_id = ?2
@@ -61,7 +75,7 @@ func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) er
}
const getAllContent = `-- name: GetAllContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = ?1
ORDER BY updated_at DESC
@@ -79,7 +93,8 @@ func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content,
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -99,7 +114,7 @@ func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content,
}
const getBulkContent = `-- name: GetBulkContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = ?1 AND id IN (/*SLICE:ids*/?)
`
@@ -132,7 +147,8 @@ func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams)
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -152,7 +168,7 @@ func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams)
}
const getContent = `-- name: GetContent :one
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
SELECT id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
FROM content
WHERE id = ?1 AND site_id = ?2
`
@@ -168,7 +184,8 @@ func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -179,13 +196,13 @@ func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content
const updateContent = `-- name: UpdateContent :one
UPDATE content
SET value = ?1, type = ?2, last_edited_by = ?3
SET html_content = ?1, type = ?2, last_edited_by = ?3
WHERE id = ?4 AND site_id = ?5
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type UpdateContentParams struct {
Value string `json:"value"`
HtmlContent string `json:"html_content"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
ID string `json:"id"`
@@ -194,7 +211,7 @@ type UpdateContentParams struct {
func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, updateContent,
arg.Value,
arg.HtmlContent,
arg.Type,
arg.LastEditedBy,
arg.ID,
@@ -204,7 +221,8 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
@@ -214,19 +232,20 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
}
const upsertContent = `-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (?1, ?2, ?3, ?4, ?5)
INSERT INTO content (id, site_id, html_content, original_template, type, last_edited_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
html_content = EXCLUDED.html_content,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
RETURNING id, site_id, html_content, original_template, type, created_at, updated_at, last_edited_by
`
type UpsertContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
@@ -235,7 +254,8 @@ func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (C
row := q.db.QueryRowContext(ctx, upsertContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.LastEditedBy,
)
@@ -243,7 +263,8 @@ func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (C
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package sqlite

View File

@@ -1,13 +1,18 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package sqlite
import (
"database/sql"
)
type Content struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@@ -18,7 +23,8 @@ type ContentVersion struct {
VersionID int64 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package sqlite
@@ -11,6 +11,7 @@ import (
type Querier interface {
CreateContent(ctx context.Context, arg CreateContentParams) (Content, error)
CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error
DeleteAllSiteContent(ctx context.Context, siteID string) error
DeleteContent(ctx context.Context, arg DeleteContentParams) error
DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error
GetAllContent(ctx context.Context, siteID string) ([]Content, error)

View File

@@ -3,7 +3,8 @@
CREATE TABLE content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
@@ -16,7 +17,8 @@ CREATE TABLE content_versions (
version_id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -2,8 +2,9 @@
CREATE TABLE IF NOT EXISTS content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL CHECK (type IN ('text', 'link')),
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
@@ -15,7 +16,8 @@ CREATE TABLE IF NOT EXISTS content_versions (
version_id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: setup.sql
package sqlite
@@ -13,8 +13,9 @@ const initializeSchema = `-- name: InitializeSchema :exec
CREATE TABLE IF NOT EXISTS content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL CHECK (type IN ('text', 'link')),
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
@@ -32,7 +33,8 @@ CREATE TABLE IF NOT EXISTS content_versions (
version_id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
html_content TEXT NOT NULL,
original_template TEXT,
type TEXT NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: versions.sql
package sqlite
@@ -11,14 +11,15 @@ import (
)
const createContentVersion = `-- name: CreateContentVersion :exec
INSERT INTO content_versions (content_id, site_id, value, type, created_by)
VALUES (?1, ?2, ?3, ?4, ?5)
INSERT INTO content_versions (content_id, site_id, html_content, original_template, type, created_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
`
type CreateContentVersionParams struct {
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedBy string `json:"created_by"`
}
@@ -27,7 +28,8 @@ func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVer
_, err := q.db.ExecContext(ctx, createContentVersion,
arg.ContentID,
arg.SiteID,
arg.Value,
arg.HtmlContent,
arg.OriginalTemplate,
arg.Type,
arg.CreatedBy,
)
@@ -51,8 +53,8 @@ func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsPa
const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many
SELECT
cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
c.value as current_value
cv.version_id, cv.content_id, cv.site_id, cv.html_content, cv.original_template, cv.type, cv.created_at, cv.created_by,
c.html_content as current_html_content
FROM content_versions cv
LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
WHERE cv.site_id = ?1
@@ -69,11 +71,12 @@ type GetAllVersionsForSiteRow struct {
VersionID int64 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HtmlContent string `json:"html_content"`
OriginalTemplate sql.NullString `json:"original_template"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`
CurrentValue sql.NullString `json:"current_value"`
CurrentHtmlContent sql.NullString `json:"current_html_content"`
}
func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) {
@@ -89,11 +92,12 @@ func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsF
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
&i.CurrentValue,
&i.CurrentHtmlContent,
); err != nil {
return nil, err
}
@@ -109,7 +113,7 @@ func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsF
}
const getContentVersion = `-- name: GetContentVersion :one
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE version_id = ?1
`
@@ -121,7 +125,8 @@ func (q *Queries) GetContentVersion(ctx context.Context, versionID int64) (Conte
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
@@ -130,7 +135,7 @@ func (q *Queries) GetContentVersion(ctx context.Context, versionID int64) (Conte
}
const getContentVersionHistory = `-- name: GetContentVersionHistory :many
SELECT version_id, content_id, site_id, value, type, created_at, created_by
SELECT version_id, content_id, site_id, html_content, original_template, type, created_at, created_by
FROM content_versions
WHERE content_id = ?1 AND site_id = ?2
ORDER BY created_at DESC
@@ -156,7 +161,8 @@ func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVe
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.HtmlContent,
&i.OriginalTemplate,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,

View File

@@ -2,6 +2,7 @@ package engine
import (
"context"
"database/sql"
"fmt"
"github.com/insertr/insertr/internal/db"
@@ -9,6 +10,14 @@ import (
"github.com/insertr/insertr/internal/db/sqlite"
)
// Helper function to convert sql.NullString to string
func getStringFromNullString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
// DatabaseClient implements ContentClient interface using the database
type DatabaseClient struct {
database *db.Database
@@ -35,7 +44,8 @@ func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, err
return &ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
@@ -51,7 +61,8 @@ func (c *DatabaseClient) GetContent(siteID, contentID string) (*ContentItem, err
return &ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
@@ -78,7 +89,8 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) (map
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}
@@ -99,7 +111,8 @@ func (c *DatabaseClient) GetBulkContent(siteID string, contentIDs []string) (map
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}
@@ -125,7 +138,8 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}
@@ -143,7 +157,8 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
items[content.ID] = ContentItem{
ID: content.ID,
SiteID: content.SiteID,
Value: content.Value,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}
@@ -154,3 +169,61 @@ func (c *DatabaseClient) GetAllContent(siteID string) (map[string]ContentItem, e
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
}
}
// CreateContent creates a new content item
func (c *DatabaseClient) CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*ContentItem, error) {
switch c.database.GetDBType() {
case "sqlite3":
content, err := c.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
ID: contentID,
SiteID: siteID,
HtmlContent: htmlContent,
OriginalTemplate: toNullString(originalTemplate),
Type: contentType,
LastEditedBy: lastEditedBy,
})
if err != nil {
return nil, err
}
return &ContentItem{
ID: content.ID,
SiteID: content.SiteID,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
case "postgresql":
content, err := c.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
ID: contentID,
SiteID: siteID,
HtmlContent: htmlContent,
OriginalTemplate: toNullString(originalTemplate),
Type: contentType,
LastEditedBy: lastEditedBy,
})
if err != nil {
return nil, err
}
return &ContentItem{
ID: content.ID,
SiteID: content.SiteID,
HTMLContent: content.HtmlContent,
OriginalTemplate: getStringFromNullString(content.OriginalTemplate),
Type: content.Type,
LastEditedBy: content.LastEditedBy,
}, nil
default:
return nil, fmt.Errorf("unsupported database type: %s", c.database.GetDBType())
}
}
// Helper function to convert string to sql.NullString
func toNullString(s string) sql.NullString {
if s == "" {
return sql.NullString{Valid: false}
}
return sql.NullString{String: s, Valid: true}
}

View File

@@ -17,14 +17,17 @@ 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{Type: "mock"}, // default
authProvider: authProvider,
injector: NewInjector(client, ""), // siteID will be set per operation
}
}
@@ -37,6 +40,7 @@ func NewContentEngineWithAuth(client ContentClient, authProvider *AuthProvider)
idGenerator: NewIDGenerator(),
client: client,
authProvider: authProvider,
injector: NewInjectorWithAuth(client, "", authProvider), // siteID will be set per operation
}
}
@@ -84,6 +88,20 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
// Add/update content attributes to the node
e.addContentAttributes(elem.Node, id, elem.Type)
// Store content and template for newly discovered elements (first-pass)
if wasGenerated && (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, elem.Type, "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)
}
}
}
// 4. Inject content if required by mode
@@ -157,7 +175,7 @@ func (e *ContentEngine) determineContentType(node *html.Node) string {
case "h1", "h2", "h3", "h4", "h5", "h6":
return "text"
case "p", "div", "section", "article", "span":
return "markdown"
return "text"
default:
return "text"
}
@@ -211,28 +229,35 @@ func (e *ContentEngine) injectContent(elements []ProcessedElement, siteID string
if contentItem != nil {
// Inject the content into the element
elem.Content = contentItem.Value
e.injectContentIntoNode(elem.Node, contentItem.Value, contentItem.Type)
elem.Content = contentItem.HTMLContent
// Update injector siteID for this operation
e.injector.siteID = siteID
e.injector.injectHTMLContent(elem.Node, contentItem.HTMLContent)
}
}
return nil
}
// injectContentIntoNode injects content value into an HTML node
func (e *ContentEngine) injectContentIntoNode(node *html.Node, content, contentType string) {
// Clear existing text content
for child := node.FirstChild; child != nil; {
next := child.NextSibling
if child.Type == html.TextNode {
node.RemoveChild(child)
// 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
}
child = next
}
// Add new text content
textNode := &html.Node{
Type: html.TextNode,
Data: content,
return strings.TrimSpace(content.String())
}
node.AppendChild(textNode)
// 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()
}

View File

@@ -1,12 +1,11 @@
package engine
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path/filepath"
"strings"
"github.com/google/uuid"
"golang.org/x/net/html"
)
@@ -36,12 +35,10 @@ func (g *IDGenerator) Generate(node *html.Node, filePath string) string {
// 3. Build readable prefix (deterministic, no runtime counting)
prefix := g.buildDeterministicPrefix(fileName, tag, primaryClass)
// 5. Add collision-resistant suffix
signature := g.createSignature(node, filePath)
hash := sha256.Sum256([]byte(signature))
suffix := hex.EncodeToString(hash[:3])
// 5. Add UUID-based suffix for guaranteed uniqueness
uuidSuffix := uuid.New().String()[:6] // Use first 6 chars of UUID
finalID := fmt.Sprintf("%s-%s", prefix, suffix)
finalID := fmt.Sprintf("%s-%s", prefix, uuidSuffix)
// Ensure uniqueness (should be guaranteed by hash, but safety check)
g.usedIDs[finalID] = true
@@ -114,14 +111,10 @@ func (g *IDGenerator) buildPrefix(fileName, tag, primaryClass string, index int)
return strings.Join(parts, "-")
}
// createSignature creates a unique signature for collision resistance
// createSignature creates a unique signature for collision resistance (DEPRECATED - using UUID now)
func (g *IDGenerator) createSignature(node *html.Node, filePath string) string {
// Minimal signature for uniqueness
tag := node.Data
classes := strings.Join(GetClasses(node), " ")
domPath := g.getSimpleDOMPath(node)
return fmt.Sprintf("%s|%s|%s|%s", filePath, domPath, tag, classes)
// This method is kept for compatibility but not used in UUID-based generation
return ""
}
// getSimpleDOMPath creates a simple DOM path for uniqueness
@@ -142,3 +135,68 @@ func (g *IDGenerator) getSimpleDOMPath(node *html.Node) string {
return strings.Join(pathParts, ">")
}
// getContentPreview extracts first 50 characters of text content for uniqueness
func (g *IDGenerator) getContentPreview(node *html.Node) string {
var text strings.Builder
g.extractTextContent(node, &text)
content := strings.TrimSpace(text.String())
if len(content) > 50 {
content = content[:50]
}
// Remove newlines and normalize whitespace
content = strings.ReplaceAll(content, "\n", " ")
content = strings.ReplaceAll(content, "\t", " ")
for strings.Contains(content, " ") {
content = strings.ReplaceAll(content, " ", " ")
}
return content
}
// extractTextContent recursively extracts text content from a node
func (g *IDGenerator) extractTextContent(node *html.Node, text *strings.Builder) {
if node.Type == html.TextNode {
text.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
g.extractTextContent(child, text)
}
}
// getSiblingIndex returns the position of this element among its siblings of the same type
func (g *IDGenerator) getSiblingIndex(node *html.Node) int {
if node.Parent == nil {
return 0
}
index := 0
tag := node.Data
classes := GetClasses(node)
for sibling := node.Parent.FirstChild; sibling != nil; sibling = sibling.NextSibling {
if sibling.Type == html.ElementNode && sibling.Data == tag {
siblingClasses := GetClasses(sibling)
// Check if classes match (for more precise positioning)
if g.classesMatch(classes, siblingClasses) {
if sibling == node {
return index
}
index++
}
}
}
return index
}
// classesMatch checks if two class lists are equivalent
func (g *IDGenerator) classesMatch(classes1, classes2 []string) bool {
if len(classes1) != len(classes2) {
return false
}
for i, class := range classes1 {
if i >= len(classes2) || class != classes2[i] {
return false
}
}
return true
}

View File

@@ -12,7 +12,6 @@ import (
type Injector struct {
client ContentClient
siteID string
mdProcessor *MarkdownProcessor
authProvider *AuthProvider
}
@@ -21,7 +20,6 @@ func NewInjector(client ContentClient, siteID string) *Injector {
return &Injector{
client: client,
siteID: siteID,
mdProcessor: NewMarkdownProcessor(),
authProvider: &AuthProvider{Type: "mock"}, // default
}
}
@@ -34,7 +32,6 @@ func NewInjectorWithAuth(client ContentClient, siteID string, authProvider *Auth
return &Injector{
client: client,
siteID: siteID,
mdProcessor: NewMarkdownProcessor(),
authProvider: authProvider,
}
}
@@ -53,17 +50,8 @@ func (i *Injector) InjectContent(element *Element, contentID string) error {
return nil
}
// Replace element content based on type
switch element.Type {
case "text":
i.injectTextContent(element.Node, contentItem.Value)
case "markdown":
i.injectMarkdownContent(element.Node, contentItem.Value)
case "link":
i.injectLinkContent(element.Node, contentItem.Value)
default:
i.injectTextContent(element.Node, contentItem.Value)
}
// Direct HTML injection for all content types
i.injectHTMLContent(element.Node, contentItem.HTMLContent)
// Add data attributes for editor functionality
i.AddContentAttributes(element.Node, contentID, element.Type)
@@ -97,65 +85,13 @@ func (i *Injector) InjectBulkContent(elements []ElementWithID) error {
continue
}
// Replace content based on type
switch elem.Element.Type {
case "text":
i.injectTextContent(elem.Element.Node, contentItem.Value)
case "markdown":
i.injectMarkdownContent(elem.Element.Node, contentItem.Value)
case "link":
i.injectLinkContent(elem.Element.Node, contentItem.Value)
default:
i.injectTextContent(elem.Element.Node, contentItem.Value)
}
// Direct HTML injection for all content types
i.injectHTMLContent(elem.Element.Node, contentItem.HTMLContent)
}
return nil
}
// injectTextContent replaces text content in an element
func (i *Injector) injectTextContent(node *html.Node, content string) {
// Remove all child nodes
for child := node.FirstChild; child != nil; {
next := child.NextSibling
node.RemoveChild(child)
child = next
}
// Add new text content
textNode := &html.Node{
Type: html.TextNode,
Data: content,
}
node.AppendChild(textNode)
}
// injectMarkdownContent handles markdown content - converts markdown to HTML
func (i *Injector) injectMarkdownContent(node *html.Node, content string) {
if content == "" {
i.injectTextContent(node, "")
return
}
// Convert markdown to HTML using server processor
htmlContent, err := i.mdProcessor.ToHTML(content)
if err != nil {
log.Printf("⚠️ Markdown conversion failed for content '%s': %v, falling back to text", content, err)
i.injectTextContent(node, content)
return
}
// Inject the HTML content
i.injectHTMLContent(node, htmlContent)
}
// injectLinkContent handles link/button content with URL extraction
func (i *Injector) injectLinkContent(node *html.Node, content string) {
// For now, just inject the text content
// TODO: Parse content for URL and text components
i.injectTextContent(node, content)
}
// injectHTMLContent safely injects HTML content into a DOM node
// Preserves the original element and only replaces its content
func (i *Injector) injectHTMLContent(node *html.Node, htmlContent string) {
@@ -172,8 +108,14 @@ func (i *Injector) injectHTMLContent(node *html.Node, htmlContent string) {
// Parse HTML string
doc, err := html.Parse(strings.NewReader(wrappedHTML))
if err != nil {
log.Printf("Failed to parse HTML content '%s': %v, falling back to text", htmlContent, err)
i.injectTextContent(node, htmlContent)
log.Printf("Failed to parse HTML content '%s': %v, falling back to text node", htmlContent, err)
// Fallback: inject as text node
i.clearNode(node)
textNode := &html.Node{
Type: html.TextNode,
Data: htmlContent,
}
node.AppendChild(textNode)
return
}

View File

@@ -1,76 +0,0 @@
package engine
import (
"bytes"
"log"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// MarkdownProcessor handles minimal markdown processing
// Supports only: **bold**, *italic*, and [link](url)
type MarkdownProcessor struct {
parser goldmark.Markdown
}
// NewMarkdownProcessor creates a new markdown processor with minimal configuration
func NewMarkdownProcessor() *MarkdownProcessor {
// Configure goldmark to only support basic inline formatting
md := goldmark.New(
goldmark.WithParserOptions(
parser.WithInlineParsers(
// Bold (**text**) and italic (*text*) - same parser handles both
util.Prioritized(parser.NewEmphasisParser(), 500),
// Links [text](url)
util.Prioritized(parser.NewLinkParser(), 600),
),
// Disable all block parsers except paragraph (no headings, lists, etc.)
parser.WithBlockParsers(
util.Prioritized(parser.NewParagraphParser(), 200),
),
),
goldmark.WithRendererOptions(
html.WithXHTML(), // <br /> instead of <br>
html.WithHardWraps(), // Line breaks become <br />
html.WithUnsafe(), // Allow existing HTML to pass through
),
)
return &MarkdownProcessor{parser: md}
}
// ToHTML converts markdown string to HTML
func (mp *MarkdownProcessor) ToHTML(markdown string) (string, error) {
if markdown == "" {
return "", nil
}
var buf bytes.Buffer
if err := mp.parser.Convert([]byte(markdown), &buf); err != nil {
log.Printf("Markdown conversion failed: %v", err)
return "", err
}
html := buf.String()
// Clean up goldmark's paragraph wrapping for inline content
// If content is wrapped in a single <p> tag, extract just the inner content
html = strings.TrimSpace(html)
if strings.HasPrefix(html, "<p>") && strings.HasSuffix(html, "</p>") {
// Check if this is a single paragraph (no other <p> tags inside)
inner := html[3 : len(html)-4] // Remove <p> and </p>
if !strings.Contains(inner, "<p>") {
// Single paragraph - return just the inner content for inline injection
return inner, nil
}
}
// Multiple paragraphs or other block content - return as-is
return html, nil
}

View File

@@ -35,7 +35,7 @@ type ContentResult struct {
type ProcessedElement struct {
Node *html.Node // HTML node
ID string // Generated content ID
Type string // Content type (text, markdown, link)
Type string // Content type (text, link)
Content string // Injected content (if any)
Generated bool // Whether ID was generated (vs existing)
Tag string // Element tag name
@@ -48,13 +48,15 @@ type ContentClient interface {
GetContent(siteID, contentID string) (*ContentItem, error)
GetBulkContent(siteID string, contentIDs []string) (map[string]ContentItem, error)
GetAllContent(siteID string) (map[string]ContentItem, error)
CreateContent(siteID, contentID, htmlContent, originalTemplate, contentType, lastEditedBy string) (*ContentItem, error)
}
// ContentItem represents a piece of content from the database
type ContentItem struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
HTMLContent string `json:"html_content"`
OriginalTemplate string `json:"original_template"`
Type string `json:"type"`
UpdatedAt string `json:"updated_at"`
LastEditedBy string `json:"last_edited_by,omitempty"`

31
lib/package-lock.json generated
View File

@@ -8,10 +8,6 @@
"name": "@insertr/lib",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"marked": "^16.2.1",
"turndown": "^7.2.1"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-terser": "^0.4.0",
@@ -68,12 +64,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -264,18 +254,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/marked": {
"version": "16.2.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz",
"integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -434,15 +412,6 @@
"engines": {
"node": ">=10"
}
},
"node_modules/turndown": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.1.tgz",
"integrity": "sha512-7YiPJw6rLClQL3oUKN3KgMaXeJJ2lAyZItclgKDurqnH61so4k4IH/qwmMva0zpuJc/FhRExBBnk7EbeFANlgQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
}
}
}

View File

@@ -33,7 +33,7 @@ export class ApiClient {
try {
const payload = {
html_markup: htmlMarkup, // Always send HTML markup - server extracts ID or generates new one
value: content,
html_content: content,
type: type,
file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation
};
@@ -66,6 +66,40 @@ export class ApiClient {
}
}
async updateContent(contentId, content) {
try {
const payload = {
html_content: content
};
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log(`✅ Content updated: ${contentId}`);
return result;
} else {
console.warn(`⚠️ Update failed (${response.status}): ${contentId}`);
return false;
}
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
console.warn(`🔌 API Server not reachable at ${this.baseUrl}`);
console.warn('💡 Start full-stack development: just dev');
} else {
console.error('Failed to update content:', contentId, error);
}
return false;
}
}
async getContentVersions(contentId) {
try {
const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);

View File

@@ -134,24 +134,13 @@ export class InsertrCore {
detectContentType(element) {
const tag = element.tagName.toLowerCase();
if (element.classList.contains('insertr-group')) {
return 'group';
// Only return database-valid types: 'text' or 'link'
if (tag === 'a' || tag === 'button') {
return 'link';
}
switch (tag) {
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
// All other elements are text content
return 'text';
case 'p':
return 'textarea';
case 'a': case 'button':
return 'link';
case 'div': case 'section':
return 'text';
case 'span':
return 'text';
default:
return 'text';
}
}
// Get all elements with their metadata, including group elements