feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py
Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state
Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed
Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
@@ -5,79 +5,71 @@ from typing import Any, Dict, List
|
||||
|
||||
def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse inline markdown formatting (bold, italic, links) into Ricos text nodes.
|
||||
Parse inline markdown formatting (bold, italic, links, code, strikethrough) into Ricos text nodes.
|
||||
Returns a list of text nodes with decorations.
|
||||
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
|
||||
Handles: **bold**, *italic*, [links](url), `code`, ~strikethrough~, and combinations.
|
||||
"""
|
||||
if not text:
|
||||
return [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {'text': '', 'decorations': []}
|
||||
}]
|
||||
|
||||
nodes = []
|
||||
|
||||
# Process text character by character to handle nested/adjacent formatting
|
||||
# This is more robust than regex for complex cases
|
||||
i = 0
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
def flush_text():
|
||||
nonlocal current_text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {'text': current_text, 'decorations': []}
|
||||
})
|
||||
current_text = ''
|
||||
|
||||
while i < len(text):
|
||||
# Check for bold **text** (must come before single * check)
|
||||
# Bold **text**
|
||||
if i < len(text) - 1 and text[i:i+2] == '**':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
|
||||
# Find closing **
|
||||
flush_text()
|
||||
end_bold = text.find('**', i + 2)
|
||||
if end_bold != -1:
|
||||
bold_text = text[i + 2:end_bold]
|
||||
# Recursively parse the bold text for nested formatting
|
||||
bold_nodes = parse_markdown_inline(bold_text)
|
||||
# Add BOLD decoration to all text nodes within
|
||||
# Per Wix API: decorations are objects with 'type' field, not strings
|
||||
for node in bold_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
node_decorations = node['textData'].get('decorations', []).copy()
|
||||
# Check if BOLD decoration already exists
|
||||
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
|
||||
if not has_bold:
|
||||
node_decorations.append({'type': 'BOLD'})
|
||||
node['textData']['decorations'] = node_decorations
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'BOLD'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = end_bold + 2
|
||||
continue
|
||||
|
||||
# Check for link [text](url)
|
||||
# Strikethrough ~text~
|
||||
elif text[i] == '~':
|
||||
flush_text()
|
||||
end_strike = text.find('~', i + 1)
|
||||
if end_strike != -1:
|
||||
strike_text = text[i + 1:end_strike]
|
||||
strike_nodes = parse_markdown_inline(strike_text)
|
||||
for node in strike_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'STRIKETHROUGH' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'STRIKETHROUGH'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = end_strike + 1
|
||||
continue
|
||||
|
||||
# Link [text](url)
|
||||
elif text[i] == '[':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find matching ]
|
||||
flush_text()
|
||||
link_end = text.find(']', i)
|
||||
if link_end != -1 and link_end < len(text) - 1 and text[link_end + 1] == '(':
|
||||
link_text = text[i + 1:link_end]
|
||||
@@ -85,12 +77,10 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
url_end = text.find(')', url_start)
|
||||
if url_end != -1:
|
||||
url = text[url_start:url_end]
|
||||
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
|
||||
# Create TEXT node with LINK decoration
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': link_text,
|
||||
'decorations': [{
|
||||
@@ -98,7 +88,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
'linkData': {
|
||||
'link': {
|
||||
'url': url,
|
||||
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
|
||||
'target': 'BLANK'
|
||||
}
|
||||
}
|
||||
}]
|
||||
@@ -107,33 +97,17 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
i = url_end + 1
|
||||
continue
|
||||
|
||||
# Check for code `text`
|
||||
# Inline code `text`
|
||||
elif text[i] == '`':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find closing `
|
||||
flush_text()
|
||||
code_end = text.find('`', i + 1)
|
||||
if code_end != -1:
|
||||
code_text = text[i + 1:code_end]
|
||||
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
|
||||
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
|
||||
# For inline code, we'll just use plain text for now
|
||||
# Wix doesn't have a CODE decoration, but we can preserve the text
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': code_text,
|
||||
'decorations': [] # CODE is not a valid decoration in Wix API
|
||||
@@ -142,39 +116,21 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
i = code_end + 1
|
||||
continue
|
||||
|
||||
# Check for italic *text* (only if not part of **)
|
||||
# Italic *text* (must come after ** check)
|
||||
elif text[i] == '*' and (i == 0 or text[i-1] != '*') and (i == len(text) - 1 or text[i+1] != '*'):
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find closing * (but not **)
|
||||
flush_text()
|
||||
italic_end = text.find('*', i + 1)
|
||||
if italic_end != -1:
|
||||
# Make sure it's not part of **
|
||||
if italic_end == len(text) - 1 or text[italic_end + 1] != '*':
|
||||
italic_text = text[i + 1:italic_end]
|
||||
italic_nodes = parse_markdown_inline(italic_text)
|
||||
# Add ITALIC decoration
|
||||
# Per Wix API: decorations are objects with 'type' field
|
||||
for node in italic_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
node_decorations = node['textData'].get('decorations', []).copy()
|
||||
# Check if ITALIC decoration already exists
|
||||
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
|
||||
if not has_italic:
|
||||
node_decorations.append({'type': 'ITALIC'})
|
||||
node['textData']['decorations'] = node_decorations
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'ITALIC' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'ITALIC'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = italic_end + 1
|
||||
continue
|
||||
@@ -183,58 +139,116 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
current_text += text[i]
|
||||
i += 1
|
||||
|
||||
# Add any remaining text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
flush_text()
|
||||
|
||||
# If no nodes created, return single plain text node
|
||||
if not nodes:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': text,
|
||||
'decorations': []
|
||||
}
|
||||
'nodes': [],
|
||||
'textData': {'text': text, 'decorations': []}
|
||||
})
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _make_code_block_node(code_text: str, language: str = '') -> Dict[str, Any]:
|
||||
"""Create a Ricos CODE_BLOCK node."""
|
||||
lines = code_text.split('\n')
|
||||
text_nodes = []
|
||||
for line in lines:
|
||||
text_nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {'text': line, 'decorations': []}
|
||||
})
|
||||
|
||||
return {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'CODE_BLOCK',
|
||||
'nodes': text_nodes,
|
||||
'codeBlockData': {
|
||||
'language': language or 'text',
|
||||
'textWrap': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_horizontal_rule_node() -> Dict[str, Any]:
|
||||
"""Create a Ricos DIVIDER node."""
|
||||
return {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'DIVIDER',
|
||||
'nodes': [],
|
||||
'dividerData': {
|
||||
'type': 'LINE',
|
||||
'lineStyle': {
|
||||
'width': 'LARGE',
|
||||
'alignment': 'CENTER'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert markdown content into valid Ricos JSON format.
|
||||
Supports headings, paragraphs, lists, bold, italic, links, and images.
|
||||
|
||||
Supports:
|
||||
- Headings (# to ######)
|
||||
- Paragraphs with inline formatting
|
||||
- Unordered lists (-, *)
|
||||
- Ordered lists (1., 2.)
|
||||
- Blockquotes (>)
|
||||
- Code blocks (```language ... ```)
|
||||
- Inline images ()
|
||||
- Horizontal rules (---, ***, ___)
|
||||
"""
|
||||
if not content:
|
||||
content = "This is a post from ALwrity."
|
||||
|
||||
nodes = []
|
||||
lines = content.split('\n')
|
||||
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not line:
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# Check for headings
|
||||
if line.startswith('#'):
|
||||
level = len(line) - len(line.lstrip('#'))
|
||||
heading_text = line.lstrip('# ').strip()
|
||||
# Code blocks (```language ... ```)
|
||||
if stripped.startswith('```'):
|
||||
language = stripped[3:].strip() or ''
|
||||
code_lines = []
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
if lines[i].strip() == '```':
|
||||
i += 1
|
||||
break
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
code_text = '\n'.join(code_lines)
|
||||
if code_text.strip():
|
||||
nodes.append(_make_code_block_node(code_text, language))
|
||||
continue
|
||||
|
||||
# Horizontal rules
|
||||
if re.match(r'^(---+|\*\*\*|___+)$', stripped):
|
||||
nodes.append(_make_horizontal_rule_node())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Headings
|
||||
if stripped.startswith('#'):
|
||||
level = len(stripped) - len(stripped.lstrip('#'))
|
||||
heading_text = stripped.lstrip('# ').strip()
|
||||
text_nodes = parse_markdown_inline(heading_text)
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
@@ -243,42 +257,38 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
'headingData': {'level': min(level, 6)}
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for blockquotes
|
||||
elif line.startswith('>'):
|
||||
quote_text = line.lstrip('> ').strip()
|
||||
# Continue reading consecutive blockquote lines
|
||||
quote_lines = [quote_text]
|
||||
# Blockquotes
|
||||
if stripped.startswith('>'):
|
||||
quote_lines = [stripped.lstrip('> ').strip()]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip().startswith('>'):
|
||||
quote_lines.append(lines[i].strip().lstrip('> ').strip())
|
||||
i += 1
|
||||
quote_content = ' '.join(quote_lines)
|
||||
text_nodes = parse_markdown_inline(quote_content)
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
blockquote_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'BLOCKQUOTE',
|
||||
'nodes': [paragraph_node],
|
||||
}
|
||||
nodes.append(blockquote_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for unordered lists (handle both '- ' and '* ' markers)
|
||||
elif (line.startswith('- ') or line.startswith('* ') or
|
||||
(line.startswith('-') and len(line) > 1 and line[1] != '-') or
|
||||
(line.startswith('*') and len(line) > 1 and line[1] != '*')):
|
||||
# Unordered lists
|
||||
if (stripped.startswith('- ') or stripped.startswith('* ') or
|
||||
(stripped.startswith('-') and len(stripped) > 1 and stripped[1] != '-') or
|
||||
(stripped.startswith('*') and len(stripped) > 1 and stripped[1] != '*')):
|
||||
list_items = []
|
||||
list_marker = '- ' if line.startswith('-') else '* '
|
||||
# Process list items
|
||||
list_marker = '- ' if stripped.startswith('-') else '* '
|
||||
|
||||
while i < len(lines):
|
||||
current_line = lines[i].strip()
|
||||
# Check if this is a list item
|
||||
is_list_item = (current_line.startswith('- ') or current_line.startswith('* ') or
|
||||
(current_line.startswith('-') and len(current_line) > 1 and current_line[1] != '-') or
|
||||
(current_line.startswith('*') and len(current_line) > 1 and current_line[1] != '*'))
|
||||
@@ -286,12 +296,9 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
if not is_list_item:
|
||||
break
|
||||
|
||||
# Extract item text (handle both '- ' and '-item' formats)
|
||||
if current_line.startswith('- ') or current_line.startswith('* '):
|
||||
item_text = current_line[2:].strip()
|
||||
elif current_line.startswith('-'):
|
||||
item_text = current_line[1:].strip()
|
||||
elif current_line.startswith('*'):
|
||||
elif current_line.startswith('-') or current_line.startswith('*'):
|
||||
item_text = current_line[1:].strip()
|
||||
else:
|
||||
item_text = current_line
|
||||
@@ -302,52 +309,41 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
# Check for nested items (indented with 2+ spaces)
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
# Must be indented and be a list marker
|
||||
if next_line.startswith(' ') and (next_line.strip().startswith('- ') or
|
||||
next_line.strip().startswith('* ') or
|
||||
(next_line.strip().startswith('-') and len(next_line.strip()) > 1) or
|
||||
(next_line.strip().startswith('*') and len(next_line.strip()) > 1)):
|
||||
if (next_line.startswith(' ') and
|
||||
(next_line.strip().startswith('- ') or next_line.strip().startswith('* '))):
|
||||
nested_text = next_line.strip()
|
||||
if nested_text.startswith('- ') or nested_text.startswith('* '):
|
||||
nested_text = nested_text[2:].strip()
|
||||
elif nested_text.startswith('-'):
|
||||
nested_text = nested_text[1:].strip()
|
||||
elif nested_text.startswith('*'):
|
||||
elif nested_text.startswith('-') or nested_text.startswith('*'):
|
||||
nested_text = nested_text[1:].strip()
|
||||
list_items.append(nested_text)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Build list items with proper formatting
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
|
||||
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
list_node_items = []
|
||||
for item_text in list_items:
|
||||
item_node_id = str(uuid.uuid4())
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
list_item_node = {
|
||||
'id': item_node_id,
|
||||
list_node_items.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'LIST_ITEM',
|
||||
'nodes': [paragraph_node]
|
||||
}
|
||||
list_node_items.append(list_item_node)
|
||||
})
|
||||
|
||||
bulleted_list_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'BULLETED_LIST',
|
||||
'nodes': list_node_items,
|
||||
}
|
||||
nodes.append(bulleted_list_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for ordered lists
|
||||
elif re.match(r'^\d+\.\s+', line):
|
||||
# Ordered lists
|
||||
if re.match(r'^\d+\.\s+', stripped):
|
||||
list_items = []
|
||||
while i < len(lines) and re.match(r'^\d+\.\s+', lines[i].strip()):
|
||||
item_text = re.sub(r'^\d+\.\s+', '', lines[i].strip())
|
||||
@@ -359,35 +355,30 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
list_items.append(nested_text)
|
||||
i += 1
|
||||
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
|
||||
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
list_node_items = []
|
||||
for item_text in list_items:
|
||||
item_node_id = str(uuid.uuid4())
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
list_item_node = {
|
||||
'id': item_node_id,
|
||||
list_node_items.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'LIST_ITEM',
|
||||
'nodes': [paragraph_node]
|
||||
}
|
||||
list_node_items.append(list_item_node)
|
||||
})
|
||||
|
||||
ordered_list_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'ORDERED_LIST',
|
||||
'nodes': list_node_items,
|
||||
}
|
||||
nodes.append(ordered_list_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for images
|
||||
elif line.startswith('!['):
|
||||
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
|
||||
# Images
|
||||
if stripped.startswith('!['):
|
||||
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', stripped)
|
||||
if img_match:
|
||||
alt_text = img_match.group(1)
|
||||
img_url = img_match.group(2)
|
||||
@@ -407,62 +398,52 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
}
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Regular paragraph
|
||||
else:
|
||||
# Collect consecutive non-empty lines as paragraph content
|
||||
para_lines = [line]
|
||||
para_lines = [stripped]
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i].strip()
|
||||
if not next_line:
|
||||
break
|
||||
# Stop if next line is a special markdown element
|
||||
if (next_line.startswith('#') or
|
||||
next_line.startswith('- ') or
|
||||
next_line.startswith('* ') or
|
||||
next_line.startswith('>') or
|
||||
next_line.startswith('![') or
|
||||
next_line.startswith('```') or
|
||||
re.match(r'^(---+|\*\*\*|___+)$', next_line) or
|
||||
re.match(r'^\d+\.\s+', next_line)):
|
||||
break
|
||||
para_lines.append(next_line)
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i].strip()
|
||||
if not next_line:
|
||||
break
|
||||
# Stop if next line is a special markdown element
|
||||
if (next_line.startswith('#') or
|
||||
next_line.startswith('- ') or
|
||||
next_line.startswith('* ') or
|
||||
next_line.startswith('>') or
|
||||
next_line.startswith('![') or
|
||||
re.match(r'^\d+\.\s+', next_line)):
|
||||
break
|
||||
para_lines.append(next_line)
|
||||
i += 1
|
||||
|
||||
para_text = ' '.join(para_lines)
|
||||
text_nodes = parse_markdown_inline(para_text)
|
||||
|
||||
# Only add paragraph if there are text nodes
|
||||
if text_nodes:
|
||||
paragraph_node = {
|
||||
'id': node_id,
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
nodes.append(paragraph_node)
|
||||
|
||||
para_text = ' '.join(para_lines)
|
||||
text_nodes = parse_markdown_inline(para_text)
|
||||
|
||||
if text_nodes:
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
})
|
||||
|
||||
# Ensure at least one node exists
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
if not nodes:
|
||||
fallback_paragraph = {
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': content[:500] if content else "This is a post from ALwrity.",
|
||||
'decorations': []
|
||||
}
|
||||
}],
|
||||
}
|
||||
nodes.append(fallback_paragraph)
|
||||
})
|
||||
|
||||
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
|
||||
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
|
||||
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
|
||||
return {
|
||||
'nodes': nodes
|
||||
}
|
||||
|
||||
|
||||
return {'nodes': nodes}
|
||||
|
||||
Reference in New Issue
Block a user