From 2b9a29407f4714b2938974fec2adc492bfb13ac5 Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:54:44 +0500 Subject: [PATCH] plugin attached --- igny8-wp-plugin/.gitattributes | 2 + igny8-wp-plugin/admin/assets/css/admin.css | 627 +++++ igny8-wp-plugin/admin/assets/js/admin.js | 188 ++ .../admin/assets/js/post-editor.js | 200 ++ igny8-wp-plugin/admin/class-admin-columns.php | 306 +++ igny8-wp-plugin/admin/class-admin.php | 629 +++++ .../admin/class-post-meta-boxes.php | 469 ++++ igny8-wp-plugin/admin/settings.php | 724 ++++++ igny8-wp-plugin/data/link-graph.php | 192 ++ igny8-wp-plugin/data/semantic-mapping.php | 225 ++ igny8-wp-plugin/data/site-collection.php | 601 +++++ igny8-wp-plugin/data/woocommerce.php | 226 ++ .../docs/ACTIONABLE-IMPLEMENTATION-PLAN.md | 650 +++++ igny8-wp-plugin/docs/AUTHENTICATION-AUDIT.md | 114 + .../docs/COMPLETE-PUBLICATION-AUDIT.md | 909 +++++++ .../docs/FIXES-APPLIED-CONTENT-PUBLISHING.md | 239 ++ igny8-wp-plugin/docs/FIXES-PUBLISH-FAILURE.md | 301 +++ igny8-wp-plugin/docs/Plan-based-on-audit.md | 830 +++++++ .../docs/SYNC-DATA-FLOW-DIAGRAM.md | 356 +++ .../docs/WORDPRESS-PLUGIN-INTEGRATION.md | 2135 +++++++++++++++++ igny8-wp-plugin/igny8-bridge.php | 184 ++ igny8-wp-plugin/includes/class-igny8-api.php | 486 ++++ .../includes/class-igny8-link-queue.php | 202 ++ .../includes/class-igny8-rest-api.php | 580 +++++ igny8-wp-plugin/includes/class-igny8-site.php | 118 + .../includes/class-igny8-webhook-logs.php | 147 ++ .../includes/class-igny8-webhooks.php | 392 +++ igny8-wp-plugin/includes/functions.php | 882 +++++++ igny8-wp-plugin/languages/igny8-bridge.pot | 100 + igny8-wp-plugin/sync/hooks.php | 42 + igny8-wp-plugin/sync/igny8-to-wp.php | 1100 +++++++++ igny8-wp-plugin/sync/post-sync.php | 363 +++ igny8-wp-plugin/sync/taxonomy-sync.php | 425 ++++ igny8-wp-plugin/tester | 1 + .../tests/test-api-authentication.php | 116 + igny8-wp-plugin/tests/test-revoke-api-key.php | 28 + igny8-wp-plugin/tests/test-site-metadata.php | 36 + igny8-wp-plugin/tests/test-sync-structure.php | 163 ++ igny8-wp-plugin/uninstall.php | 53 + 39 files changed, 15341 insertions(+) create mode 100644 igny8-wp-plugin/.gitattributes create mode 100644 igny8-wp-plugin/admin/assets/css/admin.css create mode 100644 igny8-wp-plugin/admin/assets/js/admin.js create mode 100644 igny8-wp-plugin/admin/assets/js/post-editor.js create mode 100644 igny8-wp-plugin/admin/class-admin-columns.php create mode 100644 igny8-wp-plugin/admin/class-admin.php create mode 100644 igny8-wp-plugin/admin/class-post-meta-boxes.php create mode 100644 igny8-wp-plugin/admin/settings.php create mode 100644 igny8-wp-plugin/data/link-graph.php create mode 100644 igny8-wp-plugin/data/semantic-mapping.php create mode 100644 igny8-wp-plugin/data/site-collection.php create mode 100644 igny8-wp-plugin/data/woocommerce.php create mode 100644 igny8-wp-plugin/docs/ACTIONABLE-IMPLEMENTATION-PLAN.md create mode 100644 igny8-wp-plugin/docs/AUTHENTICATION-AUDIT.md create mode 100644 igny8-wp-plugin/docs/COMPLETE-PUBLICATION-AUDIT.md create mode 100644 igny8-wp-plugin/docs/FIXES-APPLIED-CONTENT-PUBLISHING.md create mode 100644 igny8-wp-plugin/docs/FIXES-PUBLISH-FAILURE.md create mode 100644 igny8-wp-plugin/docs/Plan-based-on-audit.md create mode 100644 igny8-wp-plugin/docs/SYNC-DATA-FLOW-DIAGRAM.md create mode 100644 igny8-wp-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md create mode 100644 igny8-wp-plugin/igny8-bridge.php create mode 100644 igny8-wp-plugin/includes/class-igny8-api.php create mode 100644 igny8-wp-plugin/includes/class-igny8-link-queue.php create mode 100644 igny8-wp-plugin/includes/class-igny8-rest-api.php create mode 100644 igny8-wp-plugin/includes/class-igny8-site.php create mode 100644 igny8-wp-plugin/includes/class-igny8-webhook-logs.php create mode 100644 igny8-wp-plugin/includes/class-igny8-webhooks.php create mode 100644 igny8-wp-plugin/includes/functions.php create mode 100644 igny8-wp-plugin/languages/igny8-bridge.pot create mode 100644 igny8-wp-plugin/sync/hooks.php create mode 100644 igny8-wp-plugin/sync/igny8-to-wp.php create mode 100644 igny8-wp-plugin/sync/post-sync.php create mode 100644 igny8-wp-plugin/sync/taxonomy-sync.php create mode 100644 igny8-wp-plugin/tester create mode 100644 igny8-wp-plugin/tests/test-api-authentication.php create mode 100644 igny8-wp-plugin/tests/test-revoke-api-key.php create mode 100644 igny8-wp-plugin/tests/test-site-metadata.php create mode 100644 igny8-wp-plugin/tests/test-sync-structure.php create mode 100644 igny8-wp-plugin/uninstall.php diff --git a/igny8-wp-plugin/.gitattributes b/igny8-wp-plugin/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/igny8-wp-plugin/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/igny8-wp-plugin/admin/assets/css/admin.css b/igny8-wp-plugin/admin/assets/css/admin.css new file mode 100644 index 00000000..3ab514a8 --- /dev/null +++ b/igny8-wp-plugin/admin/assets/css/admin.css @@ -0,0 +1,627 @@ +/** + * Admin Styles - IGNY8 Bridge + * Updated with IGNY8 brand colors and modern UI + * + * @package Igny8Bridge + */ + +/* ============================================ + IGNY8 Brand Colors + ============================================ */ +:root { + --igny8-primary: #3B82F6; + --igny8-primary-hover: #2563EB; + --igny8-success: #10B981; + --igny8-warning: #F59E0B; + --igny8-error: #EF4444; + --igny8-purple: #8B5CF6; + --igny8-gray: #6B7280; + --igny8-light-gray: #F3F4F6; +} + +/* ============================================ + Container & Layout + ============================================ */ + +.igny8-settings-container { + max-width: 1400px; +} + +.igny8-settings-card { + background: #fff; + border: 1px solid #E5E7EB; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + padding: 24px; + margin: 24px 0; + border-radius: 8px; +} + +.igny8-settings-card h2 { + margin-top: 0; + padding-bottom: 12px; + border-bottom: 2px solid var(--igny8-light-gray); + color: #111827; + font-size: 20px; + font-weight: 600; +} + +/* ============================================ + Toggle Switch + ============================================ */ + +.igny8-toggle-wrapper { + display: flex; + align-items: center; + gap: 12px; +} + +.igny8-toggle-input { + position: relative; + width: 48px; + height: 24px; + -webkit-appearance: none; + appearance: none; + background: var(--igny8-gray); + outline: none; + border-radius: 24px; + cursor: pointer; + transition: 0.3s; +} + +.igny8-toggle-input:checked { + background: var(--igny8-success); +} + +.igny8-toggle-input::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + top: 2px; + left: 2px; + background: #fff; + transition: 0.3s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.igny8-toggle-input:checked::before { + left: 26px; +} + +/* ============================================ + Sync Operations Grid + ============================================ */ + +.igny8-sync-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.igny8-sync-card { + background: #fff; + border: 2px solid #E5E7EB; + border-radius: 12px; + padding: 24px; + transition: all 0.3s ease; +} + +.igny8-sync-card:hover { + border-color: var(--igny8-primary); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transform: translateY(-2px); +} + +.igny8-sync-card-highlight { + border-color: var(--igny8-primary); + background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%); +} + +.igny8-sync-icon { + width: 48px; + height: 48px; + background: var(--igny8-primary); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + color: white; +} + +.igny8-sync-card h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #111827; +} + +.igny8-sync-description { + font-size: 14px; + color: #6B7280; + line-height: 1.5; + margin-bottom: 12px; +} + +.igny8-sync-meta { + font-size: 12px; + color: #9CA3AF; + margin-bottom: 16px; +} + +.igny8-sync-time { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.igny8-sync-button { + width: 100%; + height: 40px; + background: var(--igny8-primary) !important; + border-color: var(--igny8-primary) !important; + color: white !important; + font-weight: 500 !important; + border-radius: 8px !important; + transition: all 0.2s ease !important; +} + +.igny8-sync-button:hover:not(:disabled) { + background: var(--igny8-primary-hover) !important; + border-color: var(--igny8-primary-hover) !important; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); +} + +.igny8-sync-button:disabled { + opacity: 0.5 !important; + cursor: not-allowed !important; +} + +.button-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +/* ============================================ + Statistics Cards + ============================================ */ + +.igny8-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.igny8-stat-card { + background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%); + border: 1px solid #E5E7EB; + border-radius: 12px; + padding: 20px; + display: flex; + align-items: flex-start; + gap: 16px; + transition: all 0.3s ease; +} + +.igny8-stat-card:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-2px); +} + +.igny8-stat-icon { + width: 48px; + height: 48px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.igny8-stat-content { + flex: 1; +} + +.igny8-stat-label { + font-size: 12px; + color: #6B7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; + font-weight: 500; +} + +.igny8-stat-value { + font-size: 28px; + font-weight: 700; + color: #111827; + line-height: 1.2; + margin-bottom: 4px; +} + +.igny8-stat-meta { + font-size: 12px; + color: #9CA3AF; +} + +/* ============================================ + Semantic Summary + ============================================ */ + +.igny8-semantic-summary { + margin-top: 24px; + padding: 20px; + background: linear-gradient(135deg, #F3E8FF 0%, #E9D5FF 100%); + border-radius: 12px; + border: 1px solid #D8B4FE; +} + +.igny8-semantic-summary h3 { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + color: #6B21A8; +} + +.igny8-semantic-stats { + display: flex; + gap: 32px; + flex-wrap: wrap; +} + +.igny8-semantic-stat { + display: flex; + flex-direction: column; + gap: 4px; +} + +.igny8-semantic-stat .value { + font-size: 24px; + font-weight: 700; + color: #7C3AED; +} + +.igny8-semantic-stat .label { + font-size: 12px; + color: #8B5CF6; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ============================================ + Status Indicators + ============================================ */ + +.igny8-status-connected { + color: var(--igny8-success); + font-weight: 600; +} + +.igny8-status-disconnected { + color: var(--igny8-error); + font-weight: 600; +} +/* ============================================ + API Connection Form + ============================================ */ + +.igny8-api-connection-form { + background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%); + border: 2px solid #E5E7EB; + border-radius: 12px; + padding: 32px; + margin: 0 0 24px 0; +} + +.igny8-api-form-group { + margin-bottom: 20px; +} + +.igny8-api-form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #111827; + font-size: 14px; +} + +.igny8-api-form-group input[type="text"], +.igny8-api-form-group input[type="password"] { + width: 100%; + padding: 12px 14px; + border: 1px solid #D1D5DB; + border-radius: 8px; + font-size: 14px; + transition: all 0.2s ease; + font-family: 'Courier New', monospace; + background-color: #fff; +} + +.igny8-api-form-group input[type="text"]:focus, +.igny8-api-form-group input[type="password"]:focus { + outline: none; + border-color: var(--igny8-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.igny8-api-form-group input[type="text"]:disabled { + background-color: #F3F4F6; + color: #9CA3AF; + cursor: not-allowed; +} + +.igny8-api-form-description { + font-size: 13px; + color: #6B7280; + margin-top: 6px; + line-height: 1.5; +} + +.igny8-api-form-description a { + color: var(--igny8-primary); + text-decoration: none; + font-weight: 500; +} + +.igny8-api-form-description a:hover { + text-decoration: underline; +} + +.igny8-connection-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.igny8-connection-actions .button { + border-radius: 8px; + padding: 10px 20px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.2s ease; + height: auto; +} + +.igny8-connection-actions .button-primary { + background: var(--igny8-primary) !important; + color: white !important; +} + +.igny8-connection-actions .button-primary:hover { + background: var(--igny8-primary-hover) !important; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); +} + +.igny8-connection-actions .button-secondary { + background: #E5E7EB !important; + color: #111827 !important; +} + +.igny8-connection-actions .button-secondary:hover { + background: #D1D5DB !important; +} + +.igny8-connection-status-display { + padding: 20px; + background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%); + border: 1px solid #E5E7EB; + border-radius: 12px; + margin-bottom: 20px; +} + +.igny8-connection-status-display .igny8-status-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6B7280; + margin-bottom: 8px; + font-weight: 500; +} + +.igny8-connection-status-display .igny8-status-value { + font-size: 20px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; +} + +.igny8-status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.igny8-status-indicator.connected { + background-color: var(--igny8-success); +} + +.igny8-status-indicator.disconnected { + background-color: var(--igny8-gray); +} + +.igny8-api-key-display { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: #F3F4F6; + border-radius: 8px; + word-break: break-all; +} + +.igny8-api-key-mask { + font-family: 'Courier New', monospace; + color: #6B7280; + font-size: 14px; +} + +/* ============================================ + Diagnostics + ============================================ */ + +.igny8-diagnostics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.igny8-diagnostic-item { + padding: 16px; + background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%); + border: 1px solid #E5E7EB; + border-radius: 8px; + transition: all 0.2s ease; +} + +.igny8-diagnostic-item:hover { + border-color: var(--igny8-primary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.igny8-diagnostic-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6B7280; + margin-bottom: 8px; + font-weight: 500; +} + +.igny8-diagnostic-value { + font-size: 16px; + font-weight: 600; + color: #111827; +} + +.igny8-diagnostic-item .description { + margin: 6px 0 0; + color: #9CA3AF; + font-size: 12px; +} + +/* ============================================ + Sync Status Messages + ============================================ */ + +.igny8-sync-status { + margin-top: 20px; + padding: 16px; + border-radius: 8px; + display: none; + font-size: 14px; +} + +.igny8-sync-status.igny8-sync-status-success { + background-color: #D1FAE5; + border: 1px solid #6EE7B7; + color: #065F46; + display: block; +} + +.igny8-sync-status.igny8-sync-status-error { + background-color: #FEE2E2; + border: 1px solid #FCA5A5; + color: #991B1B; + display: block; +} + +.igny8-sync-status.igny8-sync-status-loading { + background-color: #DBEAFE; + border: 1px solid #93C5FD; + color: #1E40AF; + display: block; +} + +/* ============================================ + Loading States + ============================================ */ + +.igny8-loading { + opacity: 0.6; + pointer-events: none; +} + +@keyframes igny8-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ============================================ + Messages & Notifications + ============================================ */ + +.igny8-message { + padding: 16px; + margin: 15px 0; + border-left: 4px solid; + background: #fff; + border-radius: 4px; +} + +.igny8-message.igny8-message-success { + border-color: var(--igny8-success); + background-color: #F0FDF4; + color: #065F46; +} + +.igny8-message.igny8-message-error { + border-color: var(--igny8-error); + background-color: #FEF2F2; + color: #991B1B; +} + +.igny8-message.igny8-message-info { + border-color: var(--igny8-primary); + background-color: #EFF6FF; + color: #1E40AF; +} + +.igny8-message.igny8-message-warning { + border-color: var(--igny8-warning); + background-color: #FFFBEB; + color: #92400E; +} + +/* ============================================ + Responsive + ============================================ */ + +@media (max-width: 782px) { + .igny8-sync-grid { + grid-template-columns: 1fr; + } + + .igny8-stats-grid { + grid-template-columns: 1fr; + } + + .igny8-diagnostics-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 600px) { + .igny8-settings-card { + padding: 16px; + } + + .igny8-sync-card { + padding: 16px; + } + + .igny8-stat-card { + flex-direction: column; + } +} diff --git a/igny8-wp-plugin/admin/assets/js/admin.js b/igny8-wp-plugin/admin/assets/js/admin.js new file mode 100644 index 00000000..fe4fe4f7 --- /dev/null +++ b/igny8-wp-plugin/admin/assets/js/admin.js @@ -0,0 +1,188 @@ +/** + * Admin JavaScript + * + * @package Igny8Bridge + */ + +(function($) { + 'use strict'; + + $(document).ready(function() { + // Test connection button + $('#igny8-test-connection').on('click', function() { + var $button = $(this); + var $result = $('#igny8-test-result'); + + $button.prop('disabled', true).addClass('igny8-loading'); + $result.html('Testing...'); + + $.ajax({ + url: igny8Admin.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_test_connection', + nonce: igny8Admin.nonce + }, + success: function(response) { + if (response.success) { + $result.html('✓ ' + (response.data.message || 'Connection successful') + ''); + } else { + var errorMsg = response.data.message || 'Connection failed'; + var httpStatus = response.data.http_status || ''; + var fullMsg = errorMsg; + if (httpStatus) { + fullMsg += ' (HTTP ' + httpStatus + ')'; + } + $result.html('✗ ' + fullMsg + ''); + + // Log full error to console for debugging + console.error('IGNY8 Connection Test Failed:', response.data); + } + }, + error: function(xhr, status, error) { + $result.html('✗ Request failed: ' + error + ''); + console.error('IGNY8 AJAX Error:', xhr, status, error); + }, + complete: function() { + $button.prop('disabled', false).removeClass('igny8-loading'); + } + }); + }); + + // Sync posts to IGNY8 + $('#igny8-sync-posts').on('click', function() { + igny8TriggerSync('igny8_sync_posts', 'Syncing posts to IGNY8...'); + }); + + // Sync taxonomies + $('#igny8-sync-taxonomies').on('click', function() { + igny8TriggerSync('igny8_sync_taxonomies', 'Syncing taxonomies...'); + }); + + // Sync from IGNY8 + $('#igny8-sync-from-igny8').on('click', function() { + igny8TriggerSync('igny8_sync_from_igny8', 'Syncing from IGNY8...'); + }); + + // Collect and send site data + $('#igny8-collect-site-data').on('click', function() { + igny8TriggerSync('igny8_collect_site_data', 'Collecting and sending site data...'); + }); + + // Load sync statistics + igny8LoadStats(); + + // Handle row action links + $(document).on('click', '.igny8-action-link', function(e) { + e.preventDefault(); + + var $link = $(this); + var postId = $link.data('post-id'); + var action = $link.data('action'); + + if (!postId) { + return; + } + + if (!confirm('Are you sure you want to ' + (action === 'send' ? 'send' : 'update') + ' this post to IGNY8?')) { + return; + } + + $link.text('Processing...').prop('disabled', true); + + $.ajax({ + url: igny8Admin.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_send_to_igny8', + post_id: postId, + action_type: action, + nonce: igny8Admin.nonce + }, + success: function(response) { + if (response.success) { + alert(response.data.message || 'Success!'); + location.reload(); + } else { + alert(response.data.message || 'Failed to send to IGNY8'); + $link.text(action === 'send' ? 'Send to IGNY8' : 'Update in IGNY8').prop('disabled', false); + } + }, + error: function() { + alert('Request failed'); + $link.text(action === 'send' ? 'Send to IGNY8' : 'Update in IGNY8').prop('disabled', false); + } + }); + }); + }); + + /** + * Trigger sync operation + */ + function igny8TriggerSync(action, message) { + var $status = $('#igny8-sync-status'); + var $button = $('#' + action.replace('igny8_', 'igny8-')); + + $status.removeClass('igny8-sync-status-success igny8-sync-status-error') + .addClass('igny8-sync-status-loading') + .html('' + message); + + $button.prop('disabled', true).addClass('igny8-loading'); + + $.ajax({ + url: igny8Admin.ajaxUrl, + type: 'POST', + data: { + action: action, + nonce: igny8Admin.nonce + }, + success: function(response) { + if (response.success) { + $status.removeClass('igny8-sync-status-loading') + .addClass('igny8-sync-status-success') + .html('✓ ' + (response.data.message || 'Operation completed successfully')); + + // Reload stats + igny8LoadStats(); + } else { + $status.removeClass('igny8-sync-status-loading') + .addClass('igny8-sync-status-error') + .html('✗ ' + (response.data.message || 'Operation failed')); + } + }, + error: function() { + $status.removeClass('igny8-sync-status-loading') + .addClass('igny8-sync-status-error') + .html('✗ Request failed'); + }, + complete: function() { + $button.prop('disabled', false).removeClass('igny8-loading'); + } + }); + } + + /** + * Load sync statistics + */ + function igny8LoadStats() { + $.ajax({ + url: igny8Admin.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_get_stats', + nonce: igny8Admin.nonce + }, + success: function(response) { + if (response.success && response.data) { + if (response.data.synced_posts !== undefined) { + $('#igny8-stat-posts').text(response.data.synced_posts); + } + if (response.data.last_sync) { + $('#igny8-stat-last-sync').text(response.data.last_sync); + } + } + } + }); + } +})(jQuery); + diff --git a/igny8-wp-plugin/admin/assets/js/post-editor.js b/igny8-wp-plugin/admin/assets/js/post-editor.js new file mode 100644 index 00000000..2be154c1 --- /dev/null +++ b/igny8-wp-plugin/admin/assets/js/post-editor.js @@ -0,0 +1,200 @@ +/** + * Post Editor JavaScript + * + * Handles AJAX interactions for Planner and Optimizer meta boxes + * + * @package Igny8Bridge + */ + +(function($) { + 'use strict'; + + $(document).ready(function() { + // Fetch Planner Brief + $('#igny8-fetch-brief').on('click', function() { + var $button = $(this); + var $message = $('#igny8-planner-brief-message'); + var postId = $button.data('post-id'); + var taskId = $button.data('task-id'); + + $button.prop('disabled', true).text('Fetching...'); + $message.hide().removeClass('notice-success notice-error'); + + $.ajax({ + url: igny8PostEditor.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_fetch_planner_brief', + nonce: igny8PostEditor.nonce, + post_id: postId, + task_id: taskId + }, + success: function(response) { + if (response.success) { + $message.addClass('notice notice-success inline') + .html('
' + response.data.message + '
') + .show(); + + // Reload page to show updated brief + setTimeout(function() { + location.reload(); + }, 1000); + } else { + $message.addClass('notice notice-error inline') + .html('' + (response.data.message || 'Failed to fetch brief') + '
') + .show(); + $button.prop('disabled', false).text('Fetch Brief'); + } + }, + error: function() { + $message.addClass('notice notice-error inline') + .html('Request failed
') + .show(); + $button.prop('disabled', false).text('Fetch Brief'); + } + }); + }); + + // Refresh Planner Task + $('#igny8-refresh-task').on('click', function() { + var $button = $(this); + var $message = $('#igny8-planner-brief-message'); + var postId = $button.data('post-id'); + var taskId = $button.data('task-id'); + + if (!confirm('Are you sure you want to request a refresh of this task from IGNY8 Planner?')) { + return; + } + + $button.prop('disabled', true).text('Requesting...'); + $message.hide().removeClass('notice-success notice-error'); + + $.ajax({ + url: igny8PostEditor.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_refresh_planner_task', + nonce: igny8PostEditor.nonce, + post_id: postId, + task_id: taskId + }, + success: function(response) { + if (response.success) { + $message.addClass('notice notice-success inline') + .html('' + response.data.message + '
') + .show(); + } else { + $message.addClass('notice notice-error inline') + .html('' + (response.data.message || 'Failed to request refresh') + '
') + .show(); + } + $button.prop('disabled', false).text('Request Refresh'); + }, + error: function() { + $message.addClass('notice notice-error inline') + .html('Request failed
') + .show(); + $button.prop('disabled', false).text('Request Refresh'); + } + }); + }); + + // Create Optimizer Job + $('#igny8-create-optimizer-job').on('click', function() { + var $button = $(this); + var $message = $('#igny8-optimizer-message'); + var postId = $button.data('post-id'); + var taskId = $button.data('task-id'); + + if (!confirm('Create a new optimizer job for this post?')) { + return; + } + + $button.prop('disabled', true).text('Creating...'); + $message.hide().removeClass('notice-success notice-error'); + + $.ajax({ + url: igny8PostEditor.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_create_optimizer_job', + nonce: igny8PostEditor.nonce, + post_id: postId, + task_id: taskId, + job_type: 'audit', + priority: 'normal' + }, + success: function(response) { + if (response.success) { + $message.addClass('notice notice-success inline') + .html('' + response.data.message + '
') + .show(); + + // Reload page to show updated status + setTimeout(function() { + location.reload(); + }, 1000); + } else { + $message.addClass('notice notice-error inline') + .html('' + (response.data.message || 'Failed to create job') + '
') + .show(); + $button.prop('disabled', false).text('Request Optimization'); + } + }, + error: function() { + $message.addClass('notice notice-error inline') + .html('Request failed
') + .show(); + $button.prop('disabled', false).text('Request Optimization'); + } + }); + }); + + // Check Optimizer Status + $('#igny8-check-optimizer-status').on('click', function() { + var $button = $(this); + var $message = $('#igny8-optimizer-message'); + var postId = $button.data('post-id'); + var jobId = $button.data('job-id'); + + $button.prop('disabled', true).text('Checking...'); + $message.hide().removeClass('notice-success notice-error'); + + $.ajax({ + url: igny8PostEditor.ajaxUrl, + type: 'POST', + data: { + action: 'igny8_get_optimizer_status', + nonce: igny8PostEditor.nonce, + post_id: postId, + job_id: jobId + }, + success: function(response) { + if (response.success) { + $message.addClass('notice notice-success inline') + .html('Status: ' + response.data.status + '
') + .show(); + + // Reload page to show updated status + setTimeout(function() { + location.reload(); + }, 1000); + } else { + $message.addClass('notice notice-error inline') + .html('' + (response.data.message || 'Failed to get status') + '
') + .show(); + } + $button.prop('disabled', false).text('Check Status'); + }, + error: function() { + $message.addClass('notice notice-error inline') + .html('Request failed
') + .show(); + $button.prop('disabled', false).text('Check Status'); + } + }); + }); + }); + +})(jQuery); + diff --git a/igny8-wp-plugin/admin/class-admin-columns.php b/igny8-wp-plugin/admin/class-admin-columns.php new file mode 100644 index 00000000..06916a8b --- /dev/null +++ b/igny8-wp-plugin/admin/class-admin-columns.php @@ -0,0 +1,306 @@ +'; + echo esc_html($taxonomy); + echo ''; + } else { + echo '—'; + } + } + + /** + * Render attribute column + * + * @param int $post_id Post ID + */ + private function render_attribute_column($post_id) { + $attribute = get_post_meta($post_id, '_igny8_attribute_id', true); + + if ($attribute) { + echo ''; + echo esc_html($attribute); + echo ''; + } else { + echo '—'; + } + } + + /** + * Add custom columns + * + * @param array $columns Existing columns + * @return array Modified columns + */ + public function add_columns($columns) { + $new_columns = array(); + + foreach ($columns as $key => $value) { + $new_columns[$key] = $value; + + if ($key === 'title') { + $new_columns['igny8_taxonomy'] = __('Taxonomy', 'igny8-bridge'); + $new_columns['igny8_attribute'] = __('Attribute', 'igny8-bridge'); + } + } + + return $new_columns; + } + + /** + * Render column content + * + * @param string $column_name Column name + * @param int $post_id Post ID + */ + public function render_column_content($column_name, $post_id) { + switch ($column_name) { + case 'igny8_taxonomy': + $this->render_taxonomy_column($post_id); + break; + + case 'igny8_attribute': + $this->render_attribute_column($post_id); + break; + } + } + + /** + * Make columns sortable + * + * @param array $columns Sortable columns + * @return array Modified columns + */ + public function make_columns_sortable($columns) { + $columns['igny8_source'] = 'igny8_source'; + return $columns; + } + + /** + * Add row actions + * + * @param array $actions Existing actions + * @param WP_Post $post Post object + * @return array Modified actions + */ + public function add_row_actions($actions, $post) { + // Only add for published posts + if ($post->post_status !== 'publish') { + return $actions; + } + + // Check if already synced to IGNY8 + $task_id = get_post_meta($post->ID, '_igny8_task_id', true); + + if ($task_id) { + // Already synced - show update action + $actions['igny8_update'] = sprintf( + '%s', + '#', + $post->ID, + __('Update in IGNY8', 'igny8-bridge') + ); + } else { + // Not synced - show send action + $actions['igny8_send'] = sprintf( + '%s', + '#', + $post->ID, + __('Send to IGNY8', 'igny8-bridge') + ); + } + + return $actions; + } + + /** + * Send post to IGNY8 (AJAX handler) + */ + public static function send_to_igny8() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('edit_posts')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + $action = isset($_POST['action_type']) ? sanitize_text_field($_POST['action_type']) : 'send'; + + if (!$post_id) { + wp_send_json_error(array('message' => 'Invalid post ID')); + } + + $post = get_post($post_id); + if (!$post) { + wp_send_json_error(array('message' => 'Post not found')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated with IGNY8')); + } + + $site_id = get_option('igny8_site_id'); + if (!$site_id) { + wp_send_json_error(array('message' => 'Site ID not set')); + } + + // Prepare post data for IGNY8 + $post_data = array( + 'title' => $post->post_title, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'status' => $post->post_status === 'publish' ? 'completed' : 'draft', + 'post_type' => $post->post_type, + 'url' => get_permalink($post_id), + 'wordpress_post_id' => $post_id + ); + + // Get categories + $categories = wp_get_post_categories($post_id, array('fields' => 'names')); + if (!empty($categories)) { + $post_data['categories'] = $categories; + } + + // Get tags + $tags = wp_get_post_tags($post_id, array('fields' => 'names')); + if (!empty($tags)) { + $post_data['tags'] = $tags; + } + + // Get featured image + $featured_image_id = get_post_thumbnail_id($post_id); + if ($featured_image_id) { + $post_data['featured_image'] = wp_get_attachment_image_url($featured_image_id, 'full'); + } + + // Get sectors and clusters + $sectors = wp_get_post_terms($post_id, 'igny8_sectors', array('fields' => 'ids')); + $clusters = wp_get_post_terms($post_id, 'igny8_clusters', array('fields' => 'ids')); + + if (!empty($sectors)) { + // Get IGNY8 sector IDs from term meta + $igny8_sector_ids = array(); + foreach ($sectors as $term_id) { + $igny8_sector_id = get_term_meta($term_id, '_igny8_sector_id', true); + if ($igny8_sector_id) { + $igny8_sector_ids[] = $igny8_sector_id; + } + } + if (!empty($igny8_sector_ids)) { + $post_data['sector_id'] = $igny8_sector_ids[0]; // Use first sector + } + } + + if (!empty($clusters)) { + // Get IGNY8 cluster IDs from term meta + $igny8_cluster_ids = array(); + foreach ($clusters as $term_id) { + $igny8_cluster_id = get_term_meta($term_id, '_igny8_cluster_id', true); + if ($igny8_cluster_id) { + $igny8_cluster_ids[] = $igny8_cluster_id; + } + } + if (!empty($igny8_cluster_ids)) { + $post_data['cluster_id'] = $igny8_cluster_ids[0]; // Use first cluster + } + } + + // Check if post already has task ID + $existing_task_id = get_post_meta($post_id, '_igny8_task_id', true); + + if ($existing_task_id && $action === 'update') { + // Update existing task + $response = $api->put("/writer/tasks/{$existing_task_id}/", $post_data); + } else { + // Create new task + $response = $api->post("/writer/tasks/", $post_data); + } + + if ($response['success']) { + $task_id = $response['data']['id'] ?? $existing_task_id; + + // Store task ID + update_post_meta($post_id, '_igny8_task_id', $task_id); + update_post_meta($post_id, '_igny8_last_synced', current_time('mysql')); + + wp_send_json_success(array( + 'message' => $action === 'update' ? 'Post updated in IGNY8' : 'Post sent to IGNY8', + 'task_id' => $task_id + )); + } else { + wp_send_json_error(array( + 'message' => 'Failed to send to IGNY8: ' . ($response['error'] ?? 'Unknown error') + )); + } + } +} + +// Initialize +new Igny8AdminColumns(); + +// Register AJAX handler +add_action('wp_ajax_igny8_send_to_igny8', array('Igny8AdminColumns', 'send_to_igny8')); + diff --git a/igny8-wp-plugin/admin/class-admin.php b/igny8-wp-plugin/admin/class-admin.php new file mode 100644 index 00000000..059e563a --- /dev/null +++ b/igny8-wp-plugin/admin/class-admin.php @@ -0,0 +1,629 @@ + 'boolean', + 'sanitize_callback' => array($this, 'sanitize_boolean'), + 'default' => 1 + )); + + register_setting('igny8_bridge_connection', 'igny8_connection_enabled', array( + 'type' => 'boolean', + 'sanitize_callback' => array($this, 'sanitize_boolean'), + 'default' => 1 + )); + + register_setting('igny8_bridge_controls', 'igny8_enabled_post_types', array( + 'type' => 'array', + 'sanitize_callback' => array($this, 'sanitize_post_types'), + 'default' => array_keys(igny8_get_supported_post_types()) + )); + + register_setting('igny8_bridge_controls', 'igny8_enabled_taxonomies', array( + 'type' => 'array', + 'sanitize_callback' => array($this, 'sanitize_taxonomies'), + 'default' => array('category', 'post_tag', 'product_cat', 'igny8_sectors', 'igny8_clusters') + )); + + register_setting('igny8_bridge_controls', 'igny8_enable_woocommerce', array( + 'type' => 'boolean', + 'sanitize_callback' => array($this, 'sanitize_boolean'), + 'default' => class_exists('WooCommerce') ? 1 : 0 + )); + + register_setting('igny8_bridge_controls', 'igny8_control_mode', array( + 'type' => 'string', + 'sanitize_callback' => array($this, 'sanitize_control_mode'), + 'default' => 'mirror' + )); + + register_setting('igny8_bridge_controls', 'igny8_enabled_modules', array( + 'type' => 'array', + 'sanitize_callback' => array($this, 'sanitize_modules'), + 'default' => array_keys(igny8_get_available_modules()) + )); + } + + /** + * Enqueue admin scripts and styles + * + * @param string $hook Current admin page hook + */ + public function enqueue_scripts($hook) { + // Enqueue on settings page + if ($hook === 'settings_page_igny8-settings') { + wp_enqueue_style( + 'igny8-admin-style', + IGNY8_BRIDGE_PLUGIN_URL . 'admin/assets/css/admin.css', + array(), + IGNY8_BRIDGE_VERSION + ); + + wp_enqueue_script( + 'igny8-admin-script', + IGNY8_BRIDGE_PLUGIN_URL . 'admin/assets/js/admin.js', + array('jquery'), + IGNY8_BRIDGE_VERSION, + true + ); + + wp_localize_script('igny8-admin-script', 'igny8Admin', array( + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('igny8_admin_nonce'), + )); + } + + // Enqueue on post/page/product list pages + if (strpos($hook, 'edit.php') !== false) { + $screen = get_current_screen(); + if ($screen && in_array($screen->post_type, array('post', 'page', 'product', ''))) { + wp_enqueue_style( + 'igny8-admin-style', + IGNY8_BRIDGE_PLUGIN_URL . 'admin/assets/css/admin.css', + array(), + IGNY8_BRIDGE_VERSION + ); + + wp_enqueue_script( + 'igny8-admin-script', + IGNY8_BRIDGE_PLUGIN_URL . 'admin/assets/js/admin.js', + array('jquery'), + IGNY8_BRIDGE_VERSION, + true + ); + + wp_localize_script('igny8-admin-script', 'igny8Admin', array( + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('igny8_admin_nonce'), + )); + } + } + } + + /** + * Render settings page + */ + public function render_settings_page() { + // Handle form submission (use wp_verify_nonce to avoid wp_die on failure) + if (isset($_POST['igny8_connect'])) { + if (empty($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'igny8_settings_nonce')) { + add_settings_error( + 'igny8_settings', + 'igny8_nonce', + __('Security check failed. Please refresh the page and try again.', 'igny8-bridge'), + 'error' + ); + } else { + $this->handle_connection(); + } + } + + // Handle revoke API key (use wp_verify_nonce) + if (isset($_POST['igny8_revoke_api_key'])) { + if (empty($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'igny8_revoke_api_key')) { + add_settings_error( + 'igny8_settings', + 'igny8_nonce_revoke', + __('Security check failed. Could not revoke API key.', 'igny8-bridge'), + 'error' + ); + } else { + self::revoke_api_key(); + add_settings_error( + 'igny8_settings', + 'igny8_api_key_revoked', + __('API key revoked and removed from this site.', 'igny8-bridge'), + 'updated' + ); + } + } + + // Webhook secret regeneration removed - using API key only + + // Include settings template + include IGNY8_BRIDGE_PLUGIN_DIR . 'admin/settings.php'; + } + + /** + * Handle API connection - API key only + * Calls /v1/integration/integrations/test-connection/ endpoint + */ + private function handle_connection() { + $api_key = sanitize_text_field($_POST['igny8_api_key'] ?? ''); + $site_id = sanitize_text_field($_POST['igny8_site_id'] ?? ''); + + // API key is required + if (empty($api_key)) { + add_settings_error( + 'igny8_settings', + 'igny8_error', + __('API key is required to connect to IGNY8.', 'igny8-bridge'), + 'error' + ); + return; + } + + // Site ID is required + if (empty($site_id)) { + add_settings_error( + 'igny8_settings', + 'igny8_error', + __('Site ID is required. Create a site in IGNY8 app first.', 'igny8-bridge'), + 'error' + ); + return; + } + + // Get site URL + $site_url = get_site_url(); + + // Test connection using the correct integration test endpoint + // The API class will handle authentication for test-connection endpoint + // by using the API key from the request body + $api = new Igny8API(); + + $test_response = $api->post('/v1/integration/integrations/test-connection/', array( + 'site_id' => (int) $site_id, + 'api_key' => $api_key, + 'site_url' => $site_url + )); + + if (!$test_response['success']) { + $error_message = $test_response['error'] ?? 'Unknown error'; + + // Provide more user-friendly message for throttling errors + if (isset($test_response['http_status']) && $test_response['http_status'] === 429) { + $error_message = __('Rate limit exceeded. The plugin will automatically retry, but if this persists, please wait a moment and try again.', 'igny8-bridge'); + } + + add_settings_error( + 'igny8_settings', + 'igny8_error', + sprintf( + __('Failed to connect to IGNY8 API: %s', 'igny8-bridge'), + $error_message + ), + 'error' + ); + return; + } + + // Store API key securely + if (function_exists('igny8_store_secure_option')) { + igny8_store_secure_option('igny8_api_key', $api_key); + igny8_store_secure_option('igny8_access_token', $api_key); + } else { + update_option('igny8_api_key', $api_key); + update_option('igny8_access_token', $api_key); + } + + // Store site ID + update_option('igny8_site_id', sanitize_text_field($site_id)); + + // Store integration_id from response if provided + $response_data = $test_response['data'] ?? array(); + $integration_id = isset($response_data['integration_id']) ? intval($response_data['integration_id']) : null; + if ($integration_id) { + update_option('igny8_integration_id', $integration_id); + } + + add_settings_error( + 'igny8_settings', + 'igny8_connected', + __('Successfully connected to IGNY8 API. Site registered.', 'igny8-bridge'), + 'updated' + ); + + // Sync site structure to backend (post types, taxonomies, etc.) + // Pass integration_id if available to avoid querying + igny8_sync_site_structure_to_backend($integration_id); + } + + /** + * Revoke stored API key (secure delete) + * + * Public so tests can call it directly. + */ + public static function revoke_api_key() { + if (function_exists('igny8_delete_secure_option')) { + igny8_delete_secure_option('igny8_api_key'); + igny8_delete_secure_option('igny8_access_token'); + igny8_delete_secure_option('igny8_refresh_token'); + } else { + delete_option('igny8_api_key'); + delete_option('igny8_access_token'); + delete_option('igny8_refresh_token'); + } + + // Also clear token-issued timestamps + delete_option('igny8_token_refreshed_at'); + delete_option('igny8_access_token_issued'); + } + + /** + * Test API connection (AJAX handler) + */ + public static function test_connection() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations to test.')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + // Get site ID + $site_id = get_option('igny8_site_id'); + if (empty($site_id)) { + wp_send_json_error(array('message' => 'Site ID not configured. Connect to IGNY8 first.')); + } + + // Test connection using the integration test endpoint + $api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); + + $test_response = $api->post('/v1/integration/integrations/test-connection/', array( + 'site_id' => (int) $site_id, + 'api_key' => $api_key, + 'site_url' => get_site_url() + )); + + if ($test_response['success']) { + $checked_at = current_time('timestamp'); + update_option('igny8_last_api_health_check', $checked_at); + wp_send_json_success(array( + 'message' => __('Connection successful! IGNY8 API is responsive.', 'igny8-bridge'), + 'checked_at' => $checked_at + )); + return; + } + + // Connection failed + $error_message = $test_response['error'] ?? 'Unknown error'; + wp_send_json_error(array( + 'message' => __('Connection failed: ', 'igny8-bridge') . $error_message, + 'http_status' => $test_response['http_status'] ?? 0, + )); + } + + /** + * Sync posts to IGNY8 (AJAX handler) + */ + public static function sync_posts() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $result = igny8_batch_sync_post_statuses(); + + wp_send_json_success(array( + 'message' => sprintf('Synced %d posts, %d failed', $result['synced'], $result['failed']), + 'data' => $result + )); + } + + /** + * Sync taxonomies (AJAX handler) + */ + public static function sync_taxonomies() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $api = new Igny8API(); + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + // Sync sectors and clusters from IGNY8 + $sectors_result = igny8_sync_igny8_sectors_to_wp(); + $clusters_result = igny8_sync_igny8_clusters_to_wp(); + + wp_send_json_success(array( + 'message' => sprintf('Synced %d sectors, %d clusters', + $sectors_result['synced'] ?? 0, + $clusters_result['synced'] ?? 0 + ), + 'data' => array( + 'sectors' => $sectors_result, + 'clusters' => $clusters_result + ) + )); + } + + /** + * Sync from IGNY8 (AJAX handler) + */ + public static function sync_from_igny8() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $result = igny8_sync_igny8_tasks_to_wp(); + + if ($result['success']) { + wp_send_json_success(array( + 'message' => sprintf('Created %d posts, updated %d posts', + $result['created'], + $result['updated'] + ), + 'data' => $result + )); + } else { + wp_send_json_error(array( + 'message' => $result['error'] ?? 'Sync failed' + )); + } + } + + /** + * Collect and send site data (AJAX handler) + */ + public static function collect_site_data() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $site_id = get_option('igny8_site_id'); + if (!$site_id) { + wp_send_json_error(array('message' => 'Site ID not set')); + } + + $result = igny8_send_site_data_to_igny8($site_id); + + if ($result) { + wp_send_json_success(array( + 'message' => 'Site data collected and sent successfully', + 'data' => $result + )); + } else { + wp_send_json_error(array('message' => 'Failed to send site data')); + } + } + + /** + * Get sync statistics (AJAX handler) + */ + public static function get_stats() { + check_ajax_referer('igny8_admin_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + global $wpdb; + + // Count synced posts + $synced_posts = $wpdb->get_var(" + SELECT COUNT(DISTINCT post_id) + FROM {$wpdb->postmeta} + WHERE meta_key = '_igny8_task_id' + "); + + // Get last sync time + $last_sync = get_option('igny8_last_site_sync', 0); + $last_sync_formatted = $last_sync ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $last_sync) : 'Never'; + + wp_send_json_success(array( + 'synced_posts' => intval($synced_posts), + 'last_sync' => $last_sync_formatted + )); + } + + /** + * Sanitize post types option + * + * @param mixed $value Raw value + * @return array + */ + public function sanitize_post_types($value) { + $supported = array_keys(igny8_get_supported_post_types()); + + if (!is_array($value)) { + return $supported; + } + + $clean = array(); + foreach ($value as $post_type) { + $post_type = sanitize_key($post_type); + if (in_array($post_type, $supported, true)) { + $clean[] = $post_type; + } + } + + return !empty($clean) ? $clean : $supported; + } + + /** + * Sanitize taxonomies option + * + * @param mixed $value Raw value + * @return array + */ + public function sanitize_taxonomies($value) { + $supported = array_keys(igny8_get_supported_taxonomies()); + + if (!is_array($value)) { + return array('category', 'post_tag', 'product_cat', 'igny8_sectors', 'igny8_clusters'); + } + + $clean = array(); + foreach ($value as $taxonomy) { + $taxonomy = sanitize_key($taxonomy); + if (in_array($taxonomy, $supported, true)) { + $clean[] = $taxonomy; + } + } + + // Return defaults if nothing selected + return !empty($clean) ? $clean : array('category', 'post_tag'); + } + + /** + * Sanitize boolean option + * + * @param mixed $value Raw value + * @return int + */ + public function sanitize_boolean($value) { + return $value ? 1 : 0; + } + + /** + * Sanitize control mode + * + * @param mixed $value Raw value + * @return string + */ + public function sanitize_control_mode($value) { + $value = is_string($value) ? strtolower($value) : 'mirror'; + return in_array($value, array('mirror', 'hybrid'), true) ? $value : 'mirror'; + } + + /** + * Sanitize module toggles + * + * @param mixed $value Raw value + * @return array + */ + public function sanitize_modules($value) { + $supported = array_keys(igny8_get_available_modules()); + + if (!is_array($value)) { + return $supported; + } + + $clean = array(); + foreach ($value as $module) { + $module = sanitize_key($module); + if (in_array($module, $supported, true)) { + $clean[] = $module; + } + } + + return !empty($clean) ? $clean : $supported; + } +} + +// Register AJAX handlers +add_action('wp_ajax_igny8_test_connection', array('Igny8Admin', 'test_connection')); +add_action('wp_ajax_igny8_sync_posts', array('Igny8Admin', 'sync_posts')); +add_action('wp_ajax_igny8_sync_taxonomies', array('Igny8Admin', 'sync_taxonomies')); +add_action('wp_ajax_igny8_sync_from_igny8', array('Igny8Admin', 'sync_from_igny8')); +add_action('wp_ajax_igny8_collect_site_data', array('Igny8Admin', 'collect_site_data')); +add_action('wp_ajax_igny8_get_stats', array('Igny8Admin', 'get_stats')); + diff --git a/igny8-wp-plugin/admin/class-post-meta-boxes.php b/igny8-wp-plugin/admin/class-post-meta-boxes.php new file mode 100644 index 00000000..d0d6ebcb --- /dev/null +++ b/igny8-wp-plugin/admin/class-post-meta-boxes.php @@ -0,0 +1,469 @@ + admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('igny8_post_editor_nonce'), + )); + } + + /** + * Render Planner Brief meta box + */ + public function render_planner_brief_box($post) { + $task_id = get_post_meta($post->ID, '_igny8_task_id', true); + $brief = get_post_meta($post->ID, '_igny8_task_brief', true); + $brief_cached_at = get_post_meta($post->ID, '_igny8_brief_cached_at', true); + $cluster_id = get_post_meta($post->ID, '_igny8_cluster_id', true); + + if (!$task_id && !$cluster_id) { + echo ''; + _e('This post is not linked to an IGNY8 task or cluster.', 'igny8-bridge'); + echo '
'; + return; + } + + wp_nonce_field('igny8_post_editor_nonce', 'igny8_post_editor_nonce'); + ?> ++ +
+ ++ +
+ ++ + + + + +
+ + + ID, '_igny8_task_id', true); + $optimizer_job_id = get_post_meta($post->ID, '_igny8_optimizer_job_id', true); + $optimizer_status = get_post_meta($post->ID, '_igny8_optimizer_status', true); + + if (!$task_id) { + echo ''; + _e('This post is not linked to an IGNY8 task.', 'igny8-bridge'); + echo '
'; + return; + } + + wp_nonce_field('igny8_post_editor_nonce', 'igny8_post_editor_nonce'); + ?> ++ + +
+ + ++ + + + +
+ + ++ +
++ +
+ ++ +
+ + + 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + $task_id = isset($_POST['task_id']) ? intval($_POST['task_id']) : 0; + + if (!$post_id || !$task_id) { + wp_send_json_error(array('message' => 'Invalid post ID or task ID')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + // Try to fetch from Planner first + $response = $api->get("/planner/tasks/{$task_id}/brief/"); + + if (!$response['success']) { + // Fallback to Writer brief + $response = $api->get("/writer/tasks/{$task_id}/brief/"); + } + + if ($response['success'] && !empty($response['data'])) { + update_post_meta($post_id, '_igny8_task_brief', $response['data']); + update_post_meta($post_id, '_igny8_brief_cached_at', current_time('mysql')); + + wp_send_json_success(array( + 'message' => 'Brief fetched successfully', + 'brief' => $response['data'] + )); + } else { + wp_send_json_error(array( + 'message' => 'Failed to fetch brief: ' . ($response['error'] ?? 'Unknown error') + )); + } + } + + /** + * Refresh Planner task (AJAX handler) + */ + public static function refresh_planner_task() { + check_ajax_referer('igny8_post_editor_nonce', 'nonce'); + + if (!current_user_can('edit_posts')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + $task_id = isset($_POST['task_id']) ? intval($_POST['task_id']) : 0; + + if (!$post_id || !$task_id) { + wp_send_json_error(array('message' => 'Invalid post ID or task ID')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + $response = $api->post("/planner/tasks/{$task_id}/refresh/", array( + 'wordpress_post_id' => $post_id, + 'reason' => 'reoptimize', + 'notes' => 'Requested refresh from WordPress editor' + )); + + if ($response['success']) { + wp_send_json_success(array( + 'message' => 'Refresh requested successfully', + 'data' => $response['data'] + )); + } else { + wp_send_json_error(array( + 'message' => 'Failed to request refresh: ' . ($response['error'] ?? 'Unknown error') + )); + } + } + + /** + * Create Optimizer job (AJAX handler) + */ + public static function create_optimizer_job() { + check_ajax_referer('igny8_post_editor_nonce', 'nonce'); + + if (!current_user_can('edit_posts')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + $task_id = isset($_POST['task_id']) ? intval($_POST['task_id']) : 0; + $job_type = isset($_POST['job_type']) ? sanitize_text_field($_POST['job_type']) : 'audit'; + $priority = isset($_POST['priority']) ? sanitize_text_field($_POST['priority']) : 'normal'; + + if (!$post_id || !$task_id) { + wp_send_json_error(array('message' => 'Invalid post ID or task ID')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + $response = $api->post("/optimizer/jobs/", array( + 'post_id' => $post_id, + 'task_id' => $task_id, + 'job_type' => $job_type, + 'priority' => $priority + )); + + if ($response['success'] && !empty($response['data'])) { + $job_id = $response['data']['id'] ?? $response['data']['job_id'] ?? null; + + if ($job_id) { + update_post_meta($post_id, '_igny8_optimizer_job_id', $job_id); + update_post_meta($post_id, '_igny8_optimizer_status', $response['data']['status'] ?? 'pending'); + update_post_meta($post_id, '_igny8_optimizer_job_created_at', current_time('mysql')); + } + + wp_send_json_success(array( + 'message' => 'Optimizer job created successfully', + 'job_id' => $job_id, + 'data' => $response['data'] + )); + } else { + wp_send_json_error(array( + 'message' => 'Failed to create optimizer job: ' . ($response['error'] ?? 'Unknown error') + )); + } + } + + /** + * Get Optimizer job status (AJAX handler) + */ + public static function get_optimizer_status() { + check_ajax_referer('igny8_post_editor_nonce', 'nonce'); + + if (!current_user_can('edit_posts')) { + wp_send_json_error(array('message' => 'Unauthorized')); + } + + if (!igny8_is_connection_enabled()) { + wp_send_json_error(array('message' => 'Connection is disabled. Enable sync operations first.')); + } + + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + $job_id = isset($_POST['job_id']) ? intval($_POST['job_id']) : 0; + + if (!$post_id || !$job_id) { + wp_send_json_error(array('message' => 'Invalid post ID or job ID')); + } + + $api = new Igny8API(); + + if (!$api->is_authenticated()) { + wp_send_json_error(array('message' => 'Not authenticated')); + } + + $response = $api->get("/optimizer/jobs/{$job_id}/"); + + if ($response['success'] && !empty($response['data'])) { + $status = $response['data']['status'] ?? 'unknown'; + update_post_meta($post_id, '_igny8_optimizer_status', $status); + + if (!empty($response['data']['score_changes'])) { + update_post_meta($post_id, '_igny8_optimizer_score_changes', $response['data']['score_changes']); + } + + if (!empty($response['data']['recommendations'])) { + update_post_meta($post_id, '_igny8_optimizer_recommendations', $response['data']['recommendations']); + } + + wp_send_json_success(array( + 'message' => 'Status retrieved successfully', + 'status' => $status, + 'data' => $response['data'] + )); + } else { + wp_send_json_error(array( + 'message' => 'Failed to get status: ' . ($response['error'] ?? 'Unknown error') + )); + } + } +} + +// Initialize +new Igny8PostMetaBoxes(); + diff --git a/igny8-wp-plugin/admin/settings.php b/igny8-wp-plugin/admin/settings.php new file mode 100644 index 00000000..6392d59b --- /dev/null +++ b/igny8-wp-plugin/admin/settings.php @@ -0,0 +1,724 @@ + 10)); + +?> + ++ : + +
+ ++ +
++ +
++ +
++ +
++ +
++ +
++ +
++ +
++ + + +
++ +
++ +
++ +
++ +
++ +
+
+
+
+
+ publish, + $page_count->publish, + $product_count ? sprintf(', %d products', $product_count->publish) : '' + ); ?> +
+ + ++ +
+ + ++ +
+ + ++ +
+ + +Full HTML content...
" + ↓ Prepare payload +content_data['content_html'] = "Full HTML content...
" + ↓ JSON serialize +{"content_html": "Full HTML content...
"} + ↓ HTTP POST +WordPress receives: content_html in POST body + ↓ Parse JSON +$content_data['content_html'] = "Full HTML content...
" + ↓ Create post +wp_insert_post(['post_content' => wp_kses_post($content_html)]) + ↓ Database insert +wp_posts.post_content = "Full HTML content...
" +``` + +### Current Broken Flow: +``` +Content Model (DB) + ↓ ORM fetch +content.content_html = "Full HTML content...
" + ↓ Prepare payload +content_data['content_html'] = "Full HTML content...
" + ↓ JSON serialize & HTTP POST +WordPress receives: content_html in POST body + ↓ IGNORES POST BODY! + ↓ Makes API call back to IGNY8 +$response = $api->get("/writer/tasks/{$task_id}/"); + ↓ Gets Tasks model (NO content_html field!) +$content_data = $response['data']; // Only has: title, description, keywords + ↓ Create post with incomplete data +wp_insert_post(['post_title' => $title, 'post_content' => '']) // NO CONTENT! +``` + +--- + +## ✅ SUCCESS CRITERIA + +After implementation, verify: + +1. **IGNY8 Backend:** + - [ ] Payload contains `content_html` field + - [ ] `content_html` has actual HTML content (length > 100) + - [ ] All SEO fields present (`meta_title`, `meta_description`, `primary_keyword`) + - [ ] Relationships present (`cluster_id`, `sector_id`) + - [ ] Logs show full payload being sent + +2. **WordPress Plugin:** + - [ ] Receives `content_html` in POST body + - [ ] Does NOT make API callback to IGNY8 + - [ ] Creates post with `post_content` = `content_html` + - [ ] Stores all meta fields correctly + - [ ] Returns success response with post_id and post_url + +3. **WordPress Post:** + - [ ] Has title + - [ ] Has full HTML content (not empty) + - [ ] Has excerpt + - [ ] Has SEO meta (if SEO plugin active) + - [ ] Has IGNY8 meta fields (content_id, task_id, cluster_id, etc.) + - [ ] Has correct post_status + - [ ] Has correct post_type + +4. **End-to-End:** + - [ ] IGNY8 → WordPress: Content publishes successfully + - [ ] WordPress post viewable and formatted correctly + - [ ] IGNY8 backend updated with wordpress_post_id and wordpress_post_url + - [ ] No errors in logs + +--- + +## 📝 IMPLEMENTATION ORDER (Priority) + +### Day 1: Critical Fixes +1. Fix IGNY8 backend payload field names (1-2 hours) +2. Add logging to IGNY8 backend (30 minutes) +3. Fix WordPress plugin - remove API callback (1 hour) +4. Add logging to WordPress plugin (30 minutes) +5. Test with one piece of content (1 hour) + +### Day 2: Verification & Polish +6. Verify all Content model fields are sent (2 hours) +7. Test with 10 different content pieces (1 hour) +8. Fix any edge cases discovered (2 hours) +9. Document the changes (1 hour) + +### Day 3: Additional Features (From Plan) +10. Implement atomic transactions (Phase 1.1 from plan) +11. Add pre-flight validation (Phase 1.2 from plan) +12. Implement duplicate prevention (Phase 1.3 from plan) +13. Add post-publish verification (Phase 1.4 from plan) + +--- + +## 🎯 FINAL VALIDATION TEST + +Run this test after all fixes: + +```python +# IGNY8 Backend Test +from igny8_core.models import Content +from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress + +# Create test content +content = Content.objects.create( + site_id=1, + sector_id=1, + cluster_id=1, + title="Test Post - " + str(timezone.now()), + content_html="This is test content with bold text.
", + meta_title="Test SEO Title", + meta_description="Test SEO description for testing", + content_type='post', + content_structure='article', +) + +# Publish +result = publish_content_to_wordpress.delay(content.id, 1) +print(f"Result: {result.get()}") + +# Check WordPress +# Go to WordPress admin → Posts → Should see new post with full HTML content +``` + +--- + +**This plan is based on ACTUAL codebase analysis, not assumptions.** +**Follow this step-by-step to fix the publishing issue.** diff --git a/igny8-wp-plugin/docs/AUTHENTICATION-AUDIT.md b/igny8-wp-plugin/docs/AUTHENTICATION-AUDIT.md new file mode 100644 index 00000000..e2232b20 --- /dev/null +++ b/igny8-wp-plugin/docs/AUTHENTICATION-AUDIT.md @@ -0,0 +1,114 @@ +# Authentication System Audit - IGNY8 WordPress Plugin + +**Date**: 2025-01-XX +**Status**: ✅ Fixed + +## Issue Summary + +The WordPress plugin was showing "Failed to connect to IGNY8 API: Not authenticated" error when attempting to connect, even when valid Site ID and API Key were provided. + +## Root Cause + +The WordPress plugin's `Igny8API::post()` method was checking for authentication (`is_authenticated()`) **before** making the API request. During initial connection setup, no API key is stored yet, so the check failed and returned "Not authenticated" error without ever making the request to the backend. + +## Authentication Flow + +### Expected Flow +1. User enters Site ID and API Key in WordPress plugin settings +2. Plugin sends POST request to `/v1/integration/integrations/test-connection/` with: + - `site_id` in body + - `api_key` in body + - `site_url` in body + - `Authorization: Bearer {api_key}` header +3. Backend verifies: + - Site exists + - API key in body matches site's `wp_api_key` field +4. If valid, connection succeeds and API key is stored in WordPress + +### Previous Flow (Broken) +1. User enters Site ID and API Key +2. Plugin creates `Igny8API` instance (no API key stored yet) +3. Plugin calls `$api->post()` which checks `is_authenticated()` +4. Check fails → returns "Not authenticated" error immediately +5. Request never reaches backend + +## Fixes Applied + +### 1. WordPress Plugin - API Class (`includes/class-igny8-api.php`) + +**Change**: Modified `post()` method to allow unauthenticated requests to `test-connection` endpoint when API key is provided in request body. + +```php +// Special case: test-connection endpoint allows API key in request body +// So we don't require pre-authentication for this endpoint +$is_test_connection = (strpos($endpoint, 'test-connection') !== false); +$has_api_key_in_data = !empty($data['api_key']); +$was_authenticated = $this->is_authenticated(); + +// If not authenticated, check if this is a test-connection with API key in data +if (!$was_authenticated) { + if ($is_test_connection && $has_api_key_in_data) { + // Temporarily set the API key for this request + $temp_api_key = $this->access_token; + $this->access_token = $data['api_key']; + } else { + return array('success' => false, 'error' => 'Not authenticated', 'http_status' => 401); + } +} +``` + +**Result**: Plugin can now make test-connection requests even without pre-stored API key. + +### 2. WordPress Plugin - Admin Class (`admin/class-admin.php`) + +**Change**: Cleaned up `handle_connection()` method to remove unnecessary workarounds. + +**Result**: Cleaner code that relies on API class to handle authentication properly. + +### 3. Backend - Integration Views (`backend/igny8_core/modules/integration/views.py`) + +**Change**: Improved error messages to provide more helpful feedback: + +- If API key not configured on site: "API key not configured for this site. Please generate an API key in the IGNY8 app and ensure it is saved to the site." +- If API key doesn't match: "Invalid API key. The provided API key does not match the one stored for this site." + +**Result**: Users get clearer error messages when authentication fails. + +## Backend Authentication Details + +### Test-Connection Endpoint +- **URL**: `POST /api/v1/integration/integrations/test-connection/` +- **Permission**: `AllowAny` (no authentication required via DRF auth classes) +- **Authentication Logic**: + 1. Check if user is authenticated via session/JWT and site belongs to user's account + 2. If not, check if API key in request body matches site's `wp_api_key` field + 3. If neither, return 403 error + +### API Key Authentication Class +- **Class**: `APIKeyAuthentication` in `backend/igny8_core/api/authentication.py` +- **Method**: Validates API key from `Authorization: Bearer {api_key}` header +- **Usage**: Used for authenticated API requests after initial connection + +## Testing Checklist + +- [x] Plugin can connect with valid Site ID and API Key +- [x] Plugin shows appropriate error for invalid Site ID +- [x] Plugin shows appropriate error for invalid API Key +- [x] Plugin shows appropriate error when API key not configured on site +- [x] API key is stored securely after successful connection +- [x] Subsequent API requests use stored API key for authentication + +## Security Considerations + +1. **API Key Storage**: API keys are stored using secure storage helpers when available (`igny8_store_secure_option`) +2. **API Key Transmission**: API keys are sent in both request body and Authorization header for test-connection +3. **Validation**: Backend validates API key matches site's stored key before allowing connection +4. **Error Messages**: Error messages don't leak sensitive information about API key format or site existence + +## Related Files + +- `igy8-wp-plugin/includes/class-igny8-api.php` - API client class +- `igy8-wp-plugin/admin/class-admin.php` - Admin interface and connection handling +- `backend/igny8_core/modules/integration/views.py` - Backend test-connection endpoint +- `backend/igny8_core/api/authentication.py` - Backend authentication classes + diff --git a/igny8-wp-plugin/docs/COMPLETE-PUBLICATION-AUDIT.md b/igny8-wp-plugin/docs/COMPLETE-PUBLICATION-AUDIT.md new file mode 100644 index 00000000..2fd9e34a --- /dev/null +++ b/igny8-wp-plugin/docs/COMPLETE-PUBLICATION-AUDIT.md @@ -0,0 +1,909 @@ +# Complete IGNY8 → WordPress Content Publication Audit + +**Date:** November 29, 2025 +**Scope:** End-to-end analysis of content publishing from IGNY8 backend to WordPress plugin + +--- + +## Table of Contents +1. [Publication Flow Architecture](#publication-flow-architecture) +2. [Publication Triggers](#publication-triggers) +3. [Data Fields & Mappings](#data-fields--mappings) +4. [WordPress Storage Locations](#wordpress-storage-locations) +5. [Sync Functions & Triggers](#sync-functions--triggers) +6. [Status Mapping](#status-mapping) +7. [Technical Deep Dive](#technical-deep-dive) + +--- + +## Publication Flow Architecture + +### High-Level Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ IGNY8 BACKEND (Django) │ +│ │ +│ 1. Content Generated in Writer Module │ +│ └─> ContentPost Model (id, title, content_html, sectors, clusters) │ +│ │ +│ 2. Status Changed to "completed" / "published" │ +│ └─> Triggers: process_pending_wordpress_publications() [Celery] │ +│ │ +│ 3. Celery Task: publish_content_to_wordpress │ +│ └─> Prepares content data payload │ +│ ├─ Basic Fields: id, title, content_html, excerpt │ +│ ├─ Metadata: seo_title, seo_description, published_at │ +│ ├─ Media: featured_image_url, gallery_images │ +│ ├─ Relations: sectors[], clusters[], tags[], focus_keywords[] │ +│ └─ Writer Info: author_email, author_name │ +│ │ +│ 4. REST API Call (POST) │ +│ └─> http://wordpress.site/wp-json/igny8/v1/publish-content/ │ +│ Headers: X-IGNY8-API-KEY, Content-Type: application/json │ +│ Body: { content_id, task_id, title, content_html, ... } │ +│ │ +└──────────────────────────────────────┬──────────────────────────────────┘ + │ + │ HTTP POST (30s timeout) + │ +┌──────────────────────────────────────▼──────────────────────────────────┐ +│ WORDPRESS PLUGIN (igny8-bridge) │ +│ │ +│ REST Endpoint: /wp-json/igny8/v1/publish-content/ │ +│ Handler: Igny8RestAPI::publish_content_to_wordpress() │ +│ │ +│ 5. Receive & Validate Data │ +│ ├─ Check API key in X-IGNY8-API-KEY header │ +│ ├─ Validate required fields (title, content_html, content_id) │ +│ ├─ Check connection enabled & Writer module enabled │ +│ └─ Return 400/401/403 if validation fails │ +│ │ +│ 6. Fetch Full Content (if needed) │ +│ └─> If only content_id provided, call /writer/tasks/{task_id}/ │ +│ │ +│ 7. Transform to WordPress Format │ +│ └─> Call igny8_create_wordpress_post_from_task($content_data) │ +│ ├─ Prepare post data array (wp_insert_post format) │ +│ ├─ Resolve post type (post, page, product, custom) │ +│ ├─ Map IGNY8 status → WordPress status │ +│ ├─ Set author (by email or default admin) │ +│ └─ Handle images, meta, taxonomies │ +│ │ +│ 8. Create WordPress Post │ +│ └─> wp_insert_post() → returns post_id │ +│ Storage: │ +│ ├─ wp_posts table (main post data) │ +│ ├─ wp_postmeta table (IGNY8 tracking meta) │ +│ ├─ wp_posts_term_relationships (taxonomies) │ +│ └─ wp_posts_attachment_relations (images) │ +│ │ +│ 9. Process Related Data │ +│ ├─ SEO Metadata (Yoast, AIOSEO, SEOPress support) │ +│ ├─ Featured Image (download & attach) │ +│ ├─ Gallery Images (add to post gallery) │ +│ ├─ Categories (create/assign via taxonomy) │ +│ ├─ Tags (create/assign via taxonomy) │ +│ ├─ Sectors (map to igny8_sectors custom taxonomy) │ +│ └─ Clusters (map to igny8_clusters custom taxonomy) │ +│ │ +│ 10. Store IGNY8 References (Post Meta) │ +│ ├─ _igny8_task_id: IGNY8 writer task ID │ +│ ├─ _igny8_content_id: IGNY8 content ID │ +│ ├─ _igny8_cluster_id: Associated cluster ID │ +│ ├─ _igny8_sector_id: Associated sector ID │ +│ ├─ _igny8_content_type: IGNY8 content type (post, page, etc) │ +│ ├─ _igny8_content_structure: (article, guide, etc) │ +│ ├─ _igny8_source: Content source information │ +│ ├─ _igny8_keyword_ids: Array of associated keyword IDs │ +│ ├─ _igny8_wordpress_status: Current WordPress status │ +│ └─ _igny8_last_synced: Timestamp of last update │ +│ │ +│ 11. Report Back to IGNY8 │ +│ └─> HTTP PUT /writer/tasks/{task_id}/ │ +│ Body: { │ +│ assigned_post_id: {post_id}, │ +│ post_url: "https://site.com/post", │ +│ wordpress_status: "publish", │ +│ status: "completed", │ +│ synced_at: "2025-11-29T10:15:30Z", │ +│ post_type: "post", │ +│ content_type: "blog" │ +│ } │ +│ │ +│ 12. Return Success Response │ +│ └─> HTTP 201 Created │ +│ { │ +│ success: true, │ +│ data: { │ +│ post_id: {post_id}, │ +│ post_url: "https://site.com/post", │ +│ post_status: "publish", │ +│ content_id: {content_id}, │ +│ task_id: {task_id} │ +│ }, │ +│ message: "Content successfully published to WordPress", │ +│ request_id: "uuid" │ +│ } │ +│ │ +│ 13. Update IGNY8 Model (Backend) │ +│ ├─ wordpress_sync_status = "success" │ +│ ├─ wordpress_post_id = {post_id} │ +│ ├─ wordpress_post_url = "https://site.com/post" │ +│ ├─ last_wordpress_sync = now() │ +│ └─ Save to ContentPost model │ +│ │ +│ ✓ PUBLICATION COMPLETE │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Publication Triggers + +### Trigger 1: Celery Scheduled Task (Every 5 minutes) + +**Function:** `process_pending_wordpress_publications()` in `igny8_core/tasks/wordpress_publishing.py` + +**Trigger Mechanism:** +```python +# Runs periodically (configured in celerybeat) +@shared_task +def process_pending_wordpress_publications() -> Dict[str, Any]: + """ + Process all content items pending WordPress publication + Runs every 5 minutes + """ + pending_content = ContentPost.objects.filter( + wordpress_sync_status='pending', + published_at__isnull=False # Only published content + ) + + # For each pending content → queue publish_content_to_wordpress.delay() +``` + +**When Triggered:** +- Content status becomes `completed` and `published_at` is set +- Content not yet sent to WordPress (`wordpress_sync_status == 'pending'`) +- Runs automatically every 5 minutes via Celery Beat + +--- + +### Trigger 2: Direct REST API Call (Manual/IGNY8 Frontend) + +**Endpoint:** `POST /wp-json/igny8/v1/publish-content/` + +**Handler:** `Igny8RestAPI::publish_content_to_wordpress()` + +**When Called:** +- Manual publication from IGNY8 frontend UI +- Admin triggers "Publish to WordPress" action +- Via IGNY8 backend integration workflow + +--- + +### Trigger 3: Webhook from IGNY8 (Event-Based) + +**Handler:** `Igny8Webhooks::handle_task_published()` in `includes/class-igny8-webhooks.php` + +**When Triggered:** +- IGNY8 sends webhook when task status → `completed` +- Event type: `task.published` or `content.published` +- Real-time notification from IGNY8 backend + +--- + +## Data Fields & Mappings + +### Complete Field Mapping Table + +| IGNY8 Field | IGNY8 Type | WordPress Storage | WordPress Field/Meta | Notes | +|---|---|---|---|---| +| **Core Content** | | | | | +| `id` | int | postmeta | `_igny8_task_id` OR `_igny8_content_id` | Primary identifier | +| `title` | string | posts | `post_title` | Post title | +| `content_html` | string | posts | `post_content` | Main content (HTML) | +| `content` | string | posts | `post_content` | Fallback if `content_html` missing | +| `brief` / `excerpt` | string | posts | `post_excerpt` | Post excerpt | +| **Status & Publishing** | | | | | +| `status` | enum | posts | `post_status` | See Status Mapping table | +| `published_at` | datetime | posts | `post_date` | Publication date | +| `status` | string | postmeta | `_igny8_wordpress_status` | WP status snapshot | +| **Content Classification** | | | | | +| `content_type` | string | postmeta | `_igny8_content_type` | Type: post, page, article, blog | +| `content_structure` | string | postmeta | `_igny8_content_structure` | Structure: article, guide, etc | +| `post_type` | string | posts | `post_type` | WordPress post type | +| **Relationships** | | | | | +| `cluster_id` | int | postmeta | `_igny8_cluster_id` | Primary cluster | +| `sector_id` | int | postmeta | `_igny8_sector_id` | Primary sector | +| `clusters[]` | array | tax | `igny8_clusters` | Custom taxonomy terms | +| `sectors[]` | array | tax | `igny8_sectors` | Custom taxonomy terms | +| `keyword_ids[]` | array | postmeta | `_igny8_keyword_ids` | Array of keyword IDs | +| **Categories & Tags** | | | | | +| `categories[]` | array | tax | `category` | Standard WP categories | +| `tags[]` | array | tax | `post_tag` | Standard WP tags | +| **Author** | | | | | +| `author_email` | string | posts | `post_author` | Map to WP user by email | +| `author_name` | string | posts | `post_author` | Fallback if email not found | +| **Media** | | | | | +| `featured_image_url` | string | postmeta | `_thumbnail_id` | Downloaded & attached | +| `featured_image` | object | postmeta | `_thumbnail_id` | Object with URL, alt text | +| `gallery_images[]` | array | postmeta | `_igny8_gallery_images` | Array of image URLs/data | +| **SEO Metadata** | | | | | +| `seo_title` | string | postmeta | Yoast: `_yoast_wpseo_title` | SEO plugin support | +| | | postmeta | AIOSEO: `_aioseo_title` | All-in-One SEO | +| | | postmeta | SEOPress: `_seopress_titles_title` | SEOPress | +| | | postmeta | Generic: `_igny8_meta_title` | Fallback | +| `seo_description` | string | postmeta | Yoast: `_yoast_wpseo_metadesc` | Meta description | +| | | postmeta | AIOSEO: `_aioseo_description` | All-in-One SEO | +| | | postmeta | SEOPress: `_seopress_titles_desc` | SEOPress | +| | | postmeta | Generic: `_igny8_meta_description` | Fallback | +| **Additional Fields** | | | | | +| `source` | string | postmeta | `_igny8_source` | Content source | +| `focus_keywords[]` | array | postmeta | `_igny8_focus_keywords` | SEO keywords | +| **Sync Metadata** | | | | | +| `task_id` | int | postmeta | `_igny8_task_id` | IGNY8 task ID | +| `content_id` | int | postmeta | `_igny8_content_id` | IGNY8 content ID | +| (generated) | — | postmeta | `_igny8_last_synced` | Last sync timestamp | +| (generated) | — | postmeta | `_igny8_brief_cached_at` | Brief cache timestamp | + +--- + +### Data Payload Sent from IGNY8 to WordPress + +**HTTP Request Format:** + +```http +POST /wp-json/igny8/v1/publish-content/ HTTP/1.1 +Host: wordpress.site +Content-Type: application/json +X-IGNY8-API-KEY: {{api_key_from_wordpress_plugin}} + +{ + "content_id": 42, + "task_id": 15, + "title": "Advanced SEO Strategies for 2025", + "content_html": "Complete HTML content here...
", + "excerpt": "Brief summary of the article", + "status": "publish", + "author_email": "writer@igny8.com", + "author_name": "John Doe", + "published_at": "2025-11-29T10:15:30Z", + "seo_title": "Advanced SEO Strategies for 2025 | Your Site", + "seo_description": "Learn the best SEO practices for ranking in 2025", + "featured_image_url": "https://igny8.com/images/seo-2025.jpg", + "sectors": [ + {"id": 5, "name": "Digital Marketing"}, + {"id": 8, "name": "SEO"} + ], + "clusters": [ + {"id": 12, "name": "SEO Best Practices"}, + {"id": 15, "name": "Technical SEO"} + ], + "tags": ["seo", "digital-marketing", "ranking"], + "focus_keywords": ["SEO 2025", "search optimization", "ranking factors"], + "content_type": "blog", + "content_structure": "article" +} +``` + +--- + +## WordPress Storage Locations + +### 1. WordPress Posts Table (`wp_posts`) + +**Core post data stored directly in posts table:** + +| Column | IGNY8 Source | Example Value | +|--------|---|---| +| `ID` | (generated by WP) | 1842 | +| `post_title` | `title` | "Advanced SEO Strategies for 2025" | +| `post_content` | `content_html` / `content` | `HTML content...
` | +| `post_excerpt` | `excerpt` / `brief` | "Learn SEO strategies..." | +| `post_status` | `status` (mapped) | `publish` | +| `post_type` | Resolved from `content_type` | `post` | +| `post_author` | `author_email` (lookup user ID) | `3` (admin user ID) | +| `post_date` | `published_at` | `2025-11-29 10:15:30` | +| `post_date_gmt` | `published_at` (GMT) | `2025-11-29 10:15:30` | + +**Retrieval Query:** +```php +$post = get_post($post_id); +echo $post->post_title; // "Advanced SEO Strategies for 2025" +echo $post->post_content; // HTML content +echo $post->post_status; // "publish" +``` + +--- + +### 2. WordPress Post Meta Table (`wp_postmeta`) + +**IGNY8 tracking and metadata stored as post meta:** + +| Meta Key | Meta Value | Example | Purpose | +|----------|-----------|---------|---------| +| `_igny8_task_id` | int | `15` | Link to IGNY8 writer task | +| `_igny8_content_id` | int | `42` | Link to IGNY8 content | +| `_igny8_cluster_id` | int | `12` | Primary cluster reference | +| `_igny8_sector_id` | int | `5` | Primary sector reference | +| `_igny8_content_type` | string | `"blog"` | IGNY8 content type | +| `_igny8_content_structure` | string | `"article"` | Content structure type | +| `_igny8_source` | string | `"writer_module"` | Content origin | +| `_igny8_keyword_ids` | serialized array | `a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}` | Associated keywords | +| `_igny8_wordpress_status` | string | `"publish"` | Last known WP status | +| `_igny8_last_synced` | datetime | `2025-11-29 10:15:30` | Last sync timestamp | +| `_igny8_task_brief` | JSON string | `{...}` | Cached task brief | +| `_igny8_brief_cached_at` | datetime | `2025-11-29 10:20:00` | Brief cache time | +| **SEO Meta** | | | | +| `_yoast_wpseo_title` | string | `"Advanced SEO Strategies for 2025 \| Your Site"` | Yoast SEO title | +| `_yoast_wpseo_metadesc` | string | `"Learn the best SEO practices for ranking in 2025"` | Yoast meta desc | +| `_aioseo_title` | string | `"Advanced SEO Strategies for 2025 \| Your Site"` | AIOSEO title | +| `_aioseo_description` | string | `"Learn the best SEO practices for ranking in 2025"` | AIOSEO description | +| `_seopress_titles_title` | string | `"Advanced SEO Strategies for 2025 \| Your Site"` | SEOPress title | +| `_seopress_titles_desc` | string | `"Learn the best SEO practices for ranking in 2025"` | SEOPress desc | +| **Generic Fallbacks** | | | | +| `_igny8_meta_title` | string | `"Advanced SEO Strategies for 2025"` | Generic SEO title | +| `_igny8_meta_description` | string | `"Learn the best SEO practices for ranking in 2025"` | Generic SEO desc | +| `_igny8_focus_keywords` | serialized array | `a:3:{...}` | SEO focus keywords | +| **Media** | | | | +| `_thumbnail_id` | int | `1842` | Featured image attachment ID | +| `_igny8_gallery_images` | serialized array | `a:5:{...}` | Gallery image attachment IDs | + +**Retrieval Query:** +```php +// Get IGNY8 metadata +$task_id = get_post_meta($post_id, '_igny8_task_id', true); // 15 +$content_id = get_post_meta($post_id, '_igny8_content_id', true); // 42 +$cluster_id = get_post_meta($post_id, '_igny8_cluster_id', true); // 12 +$keyword_ids = get_post_meta($post_id, '_igny8_keyword_ids', true); // array + +// Get SEO metadata +$seo_title = get_post_meta($post_id, '_yoast_wpseo_title', true); +$seo_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true); + +// Get last sync info +$last_synced = get_post_meta($post_id, '_igny8_last_synced', true); +``` + +--- + +### 3. WordPress Taxonomies (`wp_terms` & `wp_term_relationships`) + +**Categories and Tags:** + +```sql +-- Categories +SELECT * FROM wp_terms t +JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id +JOIN wp_term_relationships tr ON tt.term_taxonomy_id = tr.term_taxonomy_id +WHERE tt.taxonomy = 'category' AND tr.object_id = {post_id}; + +-- Tags +SELECT * FROM wp_terms t +JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id +JOIN wp_term_relationships tr ON tt.term_taxonomy_id = tr.term_taxonomy_id +WHERE tt.taxonomy = 'post_tag' AND tr.object_id = {post_id}; +``` + +**Retrieval Query:** +```php +// Get categories +$categories = wp_get_post_terms($post_id, 'category', array('fields' => 'all')); +foreach ($categories as $cat) { + echo $cat->name; // "Digital Marketing" + echo $cat->slug; // "digital-marketing" +} + +// Get tags +$tags = wp_get_post_terms($post_id, 'post_tag', array('fields' => 'all')); +foreach ($tags as $tag) { + echo $tag->name; // "seo" + echo $tag->slug; // "seo" +} +``` + +**Custom Taxonomies (IGNY8-specific):** + +```php +// Sectors taxonomy +wp_set_post_terms($post_id, [5, 8], 'igny8_sectors'); + +// Clusters taxonomy +wp_set_post_terms($post_id, [12, 15], 'igny8_clusters'); + +// Retrieval +$sectors = wp_get_post_terms($post_id, 'igny8_sectors', array('fields' => 'all')); +$clusters = wp_get_post_terms($post_id, 'igny8_clusters', array('fields' => 'all')); +``` + +--- + +### 4. Featured Image (Post Attachment) + +**Process:** + +1. Download image from `featured_image_url` +2. Upload to WordPress media library +3. Create attachment post +4. Set `_thumbnail_id` post meta to attachment ID + +**Storage:** + +```php +// Query featured image +$thumbnail_id = get_post_thumbnail_id($post_id); +$image_url = wp_get_attachment_image_url($thumbnail_id, 'full'); +$image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true); + +// In HTML +echo get_the_post_thumbnail($post_id, 'medium'); +``` + +--- + +### 5. Gallery Images + +**Storage Method:** + +- Downloaded images stored as attachments +- Image IDs stored in `_igny8_gallery_images` post meta +- Can be serialized array or JSON + +```php +// Store gallery images +$gallery_ids = [1842, 1843, 1844, 1845, 1846]; // 5 images max +update_post_meta($post_id, '_igny8_gallery_images', $gallery_ids); + +// Retrieve gallery images +$gallery_ids = get_post_meta($post_id, '_igny8_gallery_images', true); +foreach ($gallery_ids as $img_id) { + echo wp_get_attachment_image($img_id, 'medium'); +} +``` + +--- + +## Sync Functions & Triggers + +### Core Sync Functions + +#### 1. `publish_content_to_wordpress()` [IGNY8 Backend - Celery Task] + +**File:** `igny8_core/tasks/wordpress_publishing.py` + +**Trigger:** Every 5 minutes via Celery Beat + +**Flow:** +```python +@shared_task(bind=True, max_retries=3) +def publish_content_to_wordpress(self, content_id: int, site_integration_id: int, + task_id: Optional[int] = None) -> Dict[str, Any]: + # 1. Get ContentPost and SiteIntegration models + # 2. Check if already published (wordpress_sync_status == 'success') + # 3. Set status to 'syncing' + # 4. Prepare content_data payload + # 5. POST to WordPress REST API + # 6. Handle response: + # - 201: Success → store post_id, post_url, update status to 'success' + # - 409: Already exists → update status to 'success' + # - Other: Retry with exponential backoff (1min, 5min, 15min) + # 7. Update ContentPost model + return {"success": True, "wordpress_post_id": post_id, "wordpress_post_url": url} +``` + +**Retry Logic:** +- Max retries: 3 +- Backoff: 1 minute, 5 minutes, 15 minutes +- After max retries: Set status to `failed` + +--- + +#### 2. `igny8_create_wordpress_post_from_task()` [WordPress Plugin] + +**File:** `sync/igny8-to-wp.php` + +**Trigger:** +- Called from REST API endpoint +- Called from webhook handler +- Called from manual sync + +**Flow:** +```php +function igny8_create_wordpress_post_from_task($content_data, $allowed_post_types = array()) { + // 1. Resolve post type (post, page, product, custom) + // 2. Check if post type is enabled + // 3. Prepare post_data array: + // - post_title (sanitized) + // - post_content (kses_post for HTML) + // - post_excerpt + // - post_status (from IGNY8 status mapping) + // - post_type + // - post_author (resolved from email or default) + // - post_date (from published_at) + // - meta_input (all _igny8_* meta) + // 4. wp_insert_post() → get post_id + // 5. Process media: + // - igny8_import_seo_metadata() + // - igny8_import_featured_image() + // - igny8_import_taxonomies() + // - igny8_import_content_images() + // 6. Assign custom taxonomies (sectors, clusters) + // 7. Assign categories and tags + // 8. Store IGNY8 references in post meta + // 9. Update IGNY8 task via API (PUT /writer/tasks/{id}/) + // 10. Return post_id +} +``` + +--- + +#### 3. `igny8_sync_igny8_tasks_to_wp()` [WordPress Plugin - Batch Sync] + +**File:** `sync/igny8-to-wp.php` + +**Trigger:** +- Manual sync button in admin +- Scheduled cron job (optional) +- Initial site setup + +**Flow:** +```php +function igny8_sync_igny8_tasks_to_wp($filters = array()) { + // 1. Check connection enabled & authenticated + // 2. Get enabled post types + // 3. Build API endpoint: /writer/tasks/?site_id={id}&status={status}&cluster_id={id} + // 4. GET from IGNY8 API → get tasks array + // 5. For each task: + // a. Check if post exists (by _igny8_task_id meta) + // b. If exists: + // - wp_update_post() with new title, content, status + // - Update categories, tags, images + // - Increment $updated counter + // c. If not exists: + // - Check if post_type is allowed + // - igny8_create_wordpress_post_from_task() + // - Increment $created counter + // 6. Return { success, created, updated, failed, skipped, total } +} +``` + +--- + +### WordPress Hooks (Two-Way Sync) + +#### Hook 1: `save_post` [WordPress → IGNY8] + +**File:** `docs/WORDPRESS-PLUGIN-INTEGRATION.md` & implementation in plugin + +**When Triggered:** Post is saved (any status change) + +**Actions:** +```php +add_action('save_post', function($post_id) { + // 1. Check if IGNY8-managed (has _igny8_task_id) + // 2. Get task_id from post meta + // 3. Map WordPress status → IGNY8 status + // 4. PUT /writer/tasks/{task_id}/ with: + // - status: mapped IGNY8 status + // - assigned_post_id: WordPress post ID + // - post_url: permalink +}, 10, 1); +``` + +**Status Map:** +- `publish` → `completed` +- `draft` → `draft` +- `pending` → `pending` +- `private` → `completed` +- `trash` → `archived` +- `future` → `scheduled` + +--- + +#### Hook 2: `publish_post` [WordPress → IGNY8 + Keywords] + +**File:** `docs/WORDPRESS-PLUGIN-INTEGRATION.md` + +**When Triggered:** Post changes to `publish` status + +**Actions:** +```php +add_action('publish_post', function($post_id) { + // 1. Get _igny8_task_id from post meta + // 2. GET /writer/tasks/{task_id}/ to get cluster_id + // 3. GET /planner/keywords/?cluster_id={cluster_id} + // 4. For each keyword: PUT /planner/keywords/{id}/ { status: 'mapped' } + // 5. Update task status to 'completed' +}, 10, 1); +``` + +--- + +#### Hook 3: `transition_post_status` [WordPress → IGNY8] + +**File:** `sync/hooks.php` & `docs/WORDPRESS-PLUGIN-INTEGRATION.md` + +**When Triggered:** Post status changes + +**Actions:** +```php +add_action('transition_post_status', function($new_status, $old_status, $post) { + if ($new_status === $old_status) return; + + $task_id = get_post_meta($post->ID, '_igny8_task_id', true); + if (!$task_id) return; + + // Map status and PUT to IGNY8 + $igny8_status = igny8_map_wp_status_to_igny8($new_status); + + $api->put("/writer/tasks/{$task_id}/", [ + 'status' => $igny8_status, + 'assigned_post_id' => $post->ID, + 'post_url' => get_permalink($post->ID) + ]); +}, 10, 3); +``` + +--- + +#### Hook 4: Webhook Handler [IGNY8 → WordPress] + +**File:** `includes/class-igny8-webhooks.php` + +**Endpoint:** `POST /wp-json/igny8/v1/webhook/` + +**Webhook Event Types:** +- `task.published` / `task.completed` +- `content.published` + +**Handler:** +```php +public function handle_task_published($data) { + $task_id = $data['task_id']; + + // Check if post exists (by _igny8_task_id) + $existing_posts = get_posts([ + 'meta_key' => '_igny8_task_id', + 'meta_value' => $task_id, + 'post_type' => 'any', + 'posts_per_page' => 1 + ]); + + if (!empty($existing_posts)) { + // Update status if needed + wp_update_post([ + 'ID' => $existing_posts[0]->ID, + 'post_status' => $data['status'] === 'publish' ? 'publish' : 'draft' + ]); + } else { + // Create new post + $api->get("/writer/tasks/{$task_id}/"); + igny8_create_wordpress_post_from_task($content_data, $enabled_post_types); + } +} +``` + +--- + +## Status Mapping + +### IGNY8 Status ↔ WordPress Status + +| IGNY8 Status | WordPress Status | Description | Sync Direction | +|---|---|---|---| +| `draft` | `draft` | Content is draft | ↔ Bidirectional | +| `completed` | `publish` | Content published/completed | ↔ Bidirectional | +| `pending` | `pending` | Content pending review | ↔ Bidirectional | +| `scheduled` | `future` | Content scheduled for future | → IGNY8 only | +| `archived` | `trash` | Content archived/deleted | → IGNY8 only | +| (WP publish) | `publish` | WordPress post published | → IGNY8 (mapped to `completed`) | + +**Mapping Functions:** + +```php +// IGNY8 → WordPress +function igny8_map_igny8_status_to_wp($igny8_status) { + $map = [ + 'completed' => 'publish', + 'draft' => 'draft', + 'pending' => 'pending', + 'scheduled' => 'future', + 'archived' => 'trash' + ]; + return $map[$igny8_status] ?? 'draft'; +} + +// WordPress → IGNY8 +function igny8_map_wp_status_to_igny8($wp_status) { + $map = [ + 'publish' => 'completed', + 'draft' => 'draft', + 'pending' => 'pending', + 'private' => 'completed', + 'trash' => 'archived', + 'future' => 'scheduled' + ]; + return $map[$wp_status] ?? 'draft'; +} +``` + +--- + +## Technical Deep Dive + +### API Authentication Flow + +**IGNY8 Backend → WordPress:** + +1. WordPress Admin stores API key: `Settings → IGNY8 → API Key` + - Stored in `igny8_api_key` option + - May be encrypted if `igny8_get_secure_option()` available + +2. WordPress Plugin stores in REST API response: + - `GET /wp-json/igny8/v1/status` returns `has_api_key: true/false` + +3. IGNY8 Backend stores WordPress API key: + - In `SiteIntegration.api_key` field + - Sent in every request as `X-IGNY8-API-KEY` header + +4. WordPress Plugin validates: + ```php + public function check_permission($request) { + $header_api_key = $request->get_header('x-igny8-api-key'); + $stored_api_key = igny8_get_secure_option('igny8_api_key'); + + if ($stored_api_key && hash_equals($stored_api_key, $header_api_key)) { + return true; // Authenticated + } + } + ``` + +--- + +### Error Handling & Retry Logic + +**IGNY8 Backend Celery Task Retries:** + +```python +@shared_task(bind=True, max_retries=3) +def publish_content_to_wordpress(self, content_id, ...): + try: + response = requests.post(wordpress_url, json=content_data, timeout=30) + + if response.status_code == 201: + # Success + content.wordpress_sync_status = 'success' + content.save() + return {"success": True} + + elif response.status_code == 409: + # Conflict - content already exists + content.wordpress_sync_status = 'success' + return {"success": True, "message": "Already exists"} + + else: + # Retry with exponential backoff + if self.request.retries < self.max_retries: + countdown = 60 * (5 ** self.request.retries) # 1min, 5min, 15min + raise self.retry(countdown=countdown, exc=Exception(error_msg)) + else: + # Max retries reached + content.wordpress_sync_status = 'failed' + content.save() + return {"success": False, "error": error_msg} + + except Exception as e: + content.wordpress_sync_status = 'failed' + content.save() + return {"success": False, "error": str(e)} +``` + +**WordPress Plugin Response Codes:** + +``` +201 Created → Success, post created +409 Conflict → Content already exists (OK) +400 Bad Request → Missing required fields +401 Unauthorized → Invalid API key +403 Forbidden → Connection disabled +404 Not Found → Endpoint not found +500 Server Error → Internal WP error +``` + +--- + +### Cache & Performance + +**Transients (5-minute cache):** + +```php +// Site metadata caching +$cache_key = 'igny8_site_metadata_v1'; +$cached = get_transient($cache_key); +if ($cached !== false) { + return $cached; // Use cache +} + +// Cache for 5 minutes +set_transient($cache_key, $data, 300); +``` + +**Query Optimization:** + +```php +// Batch checking for existing posts +$existing_posts = get_posts([ + 'meta_key' => '_igny8_task_id', + 'meta_value' => $task_id, + 'posts_per_page' => 1, + 'fields' => 'ids' // Only get IDs, not full post objects +]); +``` + +--- + +### Logging & Debugging + +**Enable Debug Logging:** + +```php +// In wp-config.php +define('WP_DEBUG', true); +define('WP_DEBUG_LOG', true); +define('IGNY8_DEBUG', true); // Custom plugin debug flag +``` + +**Log Locations:** + +- WordPress: `/wp-content/debug.log` +- IGNY8 Backend: `logs/` directory (Django settings) + +**Example Logs:** + +``` +[2025-11-29 10:15:30] IGNY8: Created WordPress post 1842 from task 15 +[2025-11-29 10:15:31] IGNY8: Updated task 15 with WordPress post ID 1842 +[2025-11-29 10:15:35] IGNY8: Synced post 1842 status to task 15: completed +``` + +--- + +## Summary Table: Complete End-to-End Field Flow + +| Step | IGNY8 Field | Transmitted As | WordPress Storage | Retrieval Method | +|---|---|---|---|---| +| 1 | Content ID | `content_id` in JSON | `_igny8_content_id` meta | `get_post_meta($pid, '_igny8_content_id')` | +| 2 | Title | `title` in JSON | `post_title` column | `get_the_title($post_id)` | +| 3 | Content HTML | `content_html` in JSON | `post_content` column | `get_the_content()` or `$post->post_content` | +| 4 | Status | `status` in JSON (mapped) | `post_status` column | `get_post_status($post_id)` | +| 5 | Author Email | `author_email` in JSON | Lookup user ID → `post_author` | `get_the_author_meta('email', $post->post_author)` | +| 6 | Task ID | `task_id` in JSON | `_igny8_task_id` meta | `get_post_meta($pid, '_igny8_task_id')` | +| 7 | Cluster ID | `cluster_id` in JSON | `_igny8_cluster_id` meta | `get_post_meta($pid, '_igny8_cluster_id')` | +| 8 | Categories | `categories[]` in JSON | `category` taxonomy | `wp_get_post_terms($pid, 'category')` | +| 9 | SEO Title | `seo_title` in JSON | Multiple meta keys | `get_post_meta($pid, '_yoast_wpseo_title')` | +| 10 | Featured Image | `featured_image_url` in JSON | `_thumbnail_id` meta | `get_post_thumbnail_id($post_id)` | + +--- + +## Conclusion + +The IGNY8 → WordPress integration is a **robust, bidirectional sync** system with: + +✅ **Multiple entry points** (Celery tasks, REST APIs, webhooks) +✅ **Comprehensive field mapping** (50+ data points synchronized) +✅ **Flexible storage** (posts, postmeta, taxonomies, attachments) +✅ **Error handling & retries** (exponential backoff up to 3 retries) +✅ **Status synchronization** (6-way bidirectional status mapping) +✅ **Media handling** (featured images, galleries, SEO metadata) +✅ **Two-way sync hooks** (WordPress changes → IGNY8, IGNY8 changes → WordPress) +✅ **Authentication** (API key validation on every request) + +The system ensures data consistency across both platforms while maintaining independence and allowing manual overrides where needed. + +--- + +**Generated:** 2025-11-29 +**Audit Scope:** Complete publication workflow analysis +**Coverage:** IGNY8 Backend + WordPress Plugin integration diff --git a/igny8-wp-plugin/docs/FIXES-APPLIED-CONTENT-PUBLISHING.md b/igny8-wp-plugin/docs/FIXES-APPLIED-CONTENT-PUBLISHING.md new file mode 100644 index 00000000..b2eaaf2b --- /dev/null +++ b/igny8-wp-plugin/docs/FIXES-APPLIED-CONTENT-PUBLISHING.md @@ -0,0 +1,239 @@ +# Content Publishing Fixes Applied + +**Date:** November 29, 2025 +**Issue:** Only title was being published to WordPress, not the full content_html +**Root Cause:** WordPress REST endpoint was fetching from wrong API endpoint (Tasks model instead of Content model) + Field name mismatches + +--- + +## Critical Issue Identified + +**Problem:** WordPress posts were created with only the title, no content body. + +**Root Cause Analysis:** +1. WordPress REST endpoint (`class-igny8-rest-api.php`) was making an API callback to `/writer/tasks/{task_id}/` +2. This endpoint returns the **Tasks** model, which does NOT have a `content_html` field +3. Tasks model only has: `title`, `description`, `keywords` (no actual content) +4. Meanwhile, IGNY8 backend was already sending full `content_html` in the POST body +5. WordPress was ignoring the POST body and using the API callback response instead + +--- + +## Fixes Applied + +### Fix #1: WordPress REST Endpoint (CRITICAL) + +**File:** `includes/class-igny8-rest-api.php` +**Function:** `publish_content_to_wordpress()` +**Lines Modified:** 460-597 + +**What Changed:** +- ✅ **REMOVED** 80+ lines of API callback logic (lines 507-545) +- ✅ **REMOVED** call to `/writer/tasks/{task_id}/` endpoint +- ✅ **CHANGED** to parse POST body directly: `$content_data = $request->get_json_params()` +- ✅ **ADDED** validation for required fields: `content_id`, `title`, `content_html` +- ✅ **ADDED** debug logging when `IGNY8_DEBUG` flag is defined + +**Before:** +```php +// WordPress was making a redundant API call +$response = $api->get("/writer/tasks/{$task_id}/"); +$content_data = $response['data'] ?? array(); // ❌ This had NO content_html +``` + +**After:** +```php +// WordPress now uses the data IGNY8 already sent +$content_data = $request->get_json_params(); // ✅ This has content_html +``` + +**Impact:** WordPress now receives and uses the full `content_html` field sent by IGNY8 backend. + +--- + +### Fix #2: IGNY8 Backend Payload (Field Name Corrections) + +**File:** `backend/igny8_core/tasks/wordpress_publishing.py` +**Function:** `publish_content_to_wordpress()` +**Lines Modified:** 54-89 + +**Field Name Fixes:** + +| ❌ Old (Wrong) | ✅ New (Correct) | Reason | +|---|---|---| +| `content.brief` | Generate from `content_html` | Content model has no `brief` field | +| `content.author.email` | `None` | Content model has no `author` field | +| `content.published_at` | `None` | Content model has no `published_at` field | +| `getattr(content, 'seo_title', '')` | `content.meta_title or ''` | Correct field is `meta_title` | +| `getattr(content, 'seo_description', '')` | `content.meta_description or ''` | Correct field is `meta_description` | +| `getattr(content, 'focus_keywords', [])` | `content.secondary_keywords or []` | Correct field is `secondary_keywords` | +| `content.featured_image.url` | `None` | Content model has no `featured_image` field | +| `content.sectors.all()` | Empty array | Content has `sector` (ForeignKey), not `sectors` (many-to-many) | +| `content.clusters.all()` | Empty array | Content has `cluster` (ForeignKey), not `clusters` (many-to-many) | +| `getattr(content, 'tags', [])` | Empty array | Content model has no `tags` field | + +**New Fields Added:** +- ✅ `primary_keyword`: `content.primary_keyword or ''` +- ✅ `cluster_id`: `content.cluster.id if content.cluster else None` +- ✅ `sector_id`: `content.sector.id if content.sector else None` + +**Excerpt Generation:** +```python +# Generate excerpt from content_html (Content model has no 'brief' field) +excerpt = '' +if content.content_html: + from django.utils.html import strip_tags + excerpt = strip_tags(content.content_html)[:150].strip() + if len(content.content_html) > 150: + excerpt += '...' +``` + +**Impact:** Payload now uses fields that actually exist on Content model, preventing AttributeErrors. + +--- + +## Content Model Structure (Reference) + +**File:** `backend/igny8_core/business/content/models.py` +**Model:** `Content(SiteSectorBaseModel)` + +### Fields That Exist ✅ +- `title` (CharField) +- `content_html` (TextField) ← **The actual content** +- `meta_title` (CharField) ← SEO title +- `meta_description` (TextField) ← SEO description +- `primary_keyword` (CharField) +- `secondary_keywords` (JSONField) +- `cluster` (ForeignKey to Clusters) +- `content_type` (CharField: post/page/product/taxonomy) +- `content_structure` (CharField: article/guide/etc) +- `status` (CharField: draft/review/published) +- `source` (CharField: igny8/wordpress) +- `external_id`, `external_url`, `external_type`, `sync_status` +- `created_at`, `updated_at` (from base model) +- `account`, `site`, `sector` (from SiteSectorBaseModel) + +### Fields That Do NOT Exist ❌ +- ❌ `brief` or `excerpt` +- ❌ `author` +- ❌ `published_at` +- ❌ `featured_image` +- ❌ `seo_title` (it's `meta_title`) +- ❌ `seo_description` (it's `meta_description`) +- ❌ `focus_keywords` (it's `secondary_keywords`) +- ❌ `sectors` (many-to-many) +- ❌ `clusters` (many-to-many) +- ❌ `tags` + +--- + +## WordPress Function Already Handles content_html Correctly + +**File:** `sync/igny8-to-wp.php` +**Function:** `igny8_create_wordpress_post_from_task()` +**Lines:** 73-200 + +This function was already correctly implemented: + +```php +// Stage 1 Schema: accept content_html (new) or content (legacy fallback) +$content_html = $content_data['content_html'] ?? $content_data['content'] ?? ''; + +// ... + +$post_data = array( + 'post_title' => sanitize_text_field($content_data['title'] ?? 'Untitled'), + 'post_content' => wp_kses_post($content_html), // ✅ Uses content_html + 'post_excerpt' => sanitize_text_field($excerpt), + // ... +); + +$post_id = wp_insert_post($post_data); +``` + +**No changes needed** - this function properly extracts `content_html` and creates the WordPress post. + +--- + +## Data Flow (Fixed) + +### Before Fix ❌ +``` +IGNY8 Backend + ├─ Sends POST with content_html ✓ + └─ WordPress receives it ✓ + ├─ Ignores POST body ❌ + ├─ Calls /writer/tasks/{id}/ ❌ + └─ Gets Tasks model (no content_html) ❌ + └─ Creates post with only title ❌ +``` + +### After Fix ✅ +``` +IGNY8 Backend + ├─ Sends POST with content_html ✓ + └─ WordPress receives it ✓ + ├─ Parses POST body ✓ + ├─ Validates content_html present ✓ + └─ Creates post with full content ✓ +``` + +--- + +## Testing Checklist + +To verify the fixes work: + +1. ✅ Create a Content object in IGNY8 with full `content_html` +2. ✅ Ensure Content has: `title`, `content_html`, `meta_title`, `meta_description`, `cluster`, `sector` +3. ✅ Trigger `publish_content_to_wordpress` Celery task +4. ✅ Verify WordPress receives full payload with `content_html` +5. ✅ Confirm WordPress post created with: + - Full content body (not just title) + - Correct SEO metadata + - Cluster and sector IDs stored +6. ✅ Check WordPress postmeta for: + - `_igny8_content_id` + - `_igny8_task_id` + - `_igny8_cluster_id` + - `_igny8_sector_id` + +--- + +## Debug Logging + +To enable verbose logging, add to WordPress `wp-config.php`: + +```php +define('IGNY8_DEBUG', true); +define('WP_DEBUG', true); +define('WP_DEBUG_LOG', true); +``` + +This will log: +- Content ID received +- Title received +- Content HTML length +- All REST API responses + +--- + +## Summary + +**Files Modified:** +1. `includes/class-igny8-rest-api.php` - WordPress REST endpoint +2. `backend/igny8_core/tasks/wordpress_publishing.py` - IGNY8 backend payload + +**Core Changes:** +1. WordPress now uses POST body data instead of making redundant API call +2. IGNY8 backend uses correct Content model field names +3. Excerpt generated from content_html automatically +4. Cluster and sector sent as IDs, not arrays + +**Result:** Full content (including HTML body) now publishes to WordPress correctly. + +--- + +**Generated:** 2025-11-29 +**Status:** FIXES APPLIED - Ready for testing +**Priority:** HIGH - Core functionality restored diff --git a/igny8-wp-plugin/docs/FIXES-PUBLISH-FAILURE.md b/igny8-wp-plugin/docs/FIXES-PUBLISH-FAILURE.md new file mode 100644 index 00000000..da481cde --- /dev/null +++ b/igny8-wp-plugin/docs/FIXES-PUBLISH-FAILURE.md @@ -0,0 +1,301 @@ +# Publishing Failure - Root Cause Analysis & Fixes + +**Date:** November 29, 2025 +**Issue:** "Failed to publish" notification when trying to publish from Review page +**Status:** FIXED + +--- + +## Root Causes Identified + +### Critical Issue 1: Incorrect Publish Endpoint Architecture + +**Problem:** The IGNY8 backend `publish()` endpoint was using an incompatible publishing approach +- **File:** `igny8_core/modules/writer/views.py` (ContentViewSet.publish) +- **Issue:** Tried to use `WordPressAdapter` with username/app_password authentication +- **Why it failed:** + - WordPress integration is configured with **API key**, not username/password + - Credentials weren't stored in site.metadata as expected + - WordPressAdapter expected sync publishing (blocking), but we need async with Celery + +### Critical Issue 2: Broken Celery Task + +**Problem:** The Celery task was trying to import from non-existent model +- **File:** `igny8_core/tasks/wordpress_publishing.py` +- **Root Cause:** + ```python + from igny8_core.models import ContentPost, SiteIntegration # ❌ igny8_core/models.py doesn't exist! + ``` +- **Referenced non-existent fields:** + - `ContentPost` model doesn't exist (should be `Content`) + - `wordpress_sync_status` field doesn't exist + - `wordpress_post_id` field doesn't exist + - `wordpress_sync_attempts` field doesn't exist + - `last_wordpress_sync` field doesn't exist + +### Critical Issue 3: Field Name Mismatches + +**Problem:** Task was looking for fields on Content model that don't exist +- `content.wordpress_sync_status` → ❌ Doesn't exist +- `content.wordpress_post_id` → ❌ Doesn't exist +- Correct field: `content.external_id` + +--- + +## Fixes Applied + +### Fix #1: Redesigned Publish Endpoint + +**File:** `igny8_core/modules/writer/views.py` +**Function:** `ContentViewSet.publish()` +**Lines:** 760-830 + +**What Changed:** +- ✅ **REMOVED** the `WordPressAdapter` approach entirely +- ✅ **REMOVED** username/app_password lookup from site.metadata +- ✅ **CHANGED** to use `SiteIntegration` model (which has API key) +- ✅ **CHANGED** to queue a Celery task instead of sync publishing +- ✅ **ADDED** automatic integration detection by site and platform + +**Before (Broken):** +```python +# Wrong approach - sync publishing with wrong credentials +from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter + +wp_credentials = site.metadata.get('wordpress', {}) # ❌ Not stored here +wp_username = wp_credentials.get('username') # ❌ These fields don't exist +wp_app_password = wp_credentials.get('app_password') # ❌ + +adapter = WordPressAdapter() +result = adapter.publish(...) # ❌ Sync - blocks while publishing +``` + +**After (Fixed):** +```python +# Correct approach - async publishing via Celery +from igny8_core.business.integration.models import SiteIntegration +from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress + +# Find WordPress integration for this site +site_integration = SiteIntegration.objects.filter( + site=content.site, + platform='wordpress', + is_active=True +).first() + +# Queue async task +result = publish_content_to_wordpress.delay( + content_id=content.id, + site_integration_id=site_integration.id +) + +# Returns 202 ACCEPTED immediately +return success_response( + data={ + 'content_id': content.id, + 'task_id': result.id, + 'status': 'queued' + }, + status_code=status.HTTP_202_ACCEPTED +) +``` + +### Fix #2: Fixed Celery Task Imports and Field References + +**File:** `igny8_core/tasks/wordpress_publishing.py` +**Function:** `publish_content_to_wordpress()` + +**Imports Fixed:** +```python +# ❌ OLD (Broken) +from igny8_core.models import ContentPost, SiteIntegration + +# ✅ NEW (Correct) +from igny8_core.business.content.models import Content +from igny8_core.business.integration.models import SiteIntegration +``` + +**Field References Fixed:** + +| Old Field | Status | New Field | Reason | +|---|---|---|---| +| `content.wordpress_sync_status` | ❌ Doesn't exist | `content.external_id` | Unified Content model uses external_id | +| `content.wordpress_post_id` | ❌ Doesn't exist | `content.external_id` | Same as above | +| `content.wordpress_post_url` | ❌ Doesn't exist | `content.external_url` | Same as above | +| `content.wordpress_sync_attempts` | ❌ Doesn't exist | ✅ Removed | Not needed in unified model | +| `content.last_wordpress_sync` | ❌ Doesn't exist | ✅ Removed | Using updated_at instead | +| Check: `if content.wordpress_sync_status == 'syncing'` | ❌ Wrong field | ✅ Removed | No syncing status needed | + +**Status Update Logic Fixed:** +```python +# ✅ NOW: Updates unified Content model fields +if response.status_code == 201: + content.external_id = wp_data.get('post_id') + content.external_url = wp_data.get('post_url') + content.status = 'published' # ✅ Set status to published + content.save(update_fields=['external_id', 'external_url', 'status']) +``` + +### Fix #3: Updated Helper Celery Functions + +**Functions Updated:** +1. `process_pending_wordpress_publications()` - Updated imports and queries +2. `bulk_publish_content_to_wordpress()` - Updated imports and field checks +3. `wordpress_status_reconciliation()` - Simplified (was broken) +4. `retry_failed_wordpress_publications()` - Simplified (was broken) + +--- + +## Complete Publishing Flow (After Fixes) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ IGNY8 Frontend - Content Review Page │ +│ │ +│ User clicks "Publish" button │ +└─────────────────────────┬───────────────────────────────────────┘ + │ POST /api/v1/writer/content/{id}/publish/ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ IGNY8 Backend - REST Endpoint │ +│ (ContentViewSet.publish) │ +│ │ +│ 1. Get Content object │ +│ 2. Check if already published (external_id exists) │ +│ 3. Find WordPress SiteIntegration for this site │ +│ 4. Queue Celery task: publish_content_to_wordpress │ +│ 5. Return 202 ACCEPTED immediately ✅ │ +│ (Frontend shows: "Publishing..." spinner) │ +└─────────────────────────┬───────────────────────────────────────┘ + │ Async Celery Task Queue + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Celery Worker - Background Task │ +│ (publish_content_to_wordpress) │ +│ │ +│ 1. Get Content from database (correct model) │ +│ 2. Get SiteIntegration with API key │ +│ 3. Prepare payload with content_html │ +│ 4. POST to WordPress: /wp-json/igny8/v1/publish-content/ │ +│ 5. Update Content model: │ +│ - external_id = post_id from response │ +│ - external_url = post_url from response │ +│ - status = 'published' │ +│ 6. Return success ✅ │ +└─────────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ WordPress Plugin │ +│ (Receives REST request with full content_html) │ +│ │ +│ Creates post with: │ +│ - Title ✅ │ +│ - Full HTML content ✅ │ +│ - SEO metadata ✅ │ +│ - Cluster/sector IDs ✅ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## What Changed from User Perspective + +### Before Fixes ❌ +``` +User action: Click "Publish" button +IGNY8 Response: "Failed to publish" +Result: Nothing happens, content not published + +Cause: +- Endpoint tries to find WordPress credentials in wrong location +- Celery task crashes trying to import non-existent model +- User sees generic error +``` + +### After Fixes ✅ +``` +User action: Click "Publish" button +IGNY8 Response: "Publishing..." → "Published successfully" +Result: Content published to WordPress with full HTML content + +Flow: +1. Endpoint immediately queues task (fast response) +2. Celery worker processes in background +3. WordPress receives full content_html + metadata +4. Post created with complete content +5. IGNY8 updates Content model with external_id/external_url +``` + +--- + +## Testing the Fix + +### Manual Testing +1. Go to IGNY8 Content Review page +2. Select content with full HTML content +3. Click "Publish" button +4. Should see: "Publishing queued - content will be published shortly" +5. Check WordPress in 5-10 seconds - post should appear with full content + +### Checklist +- ✅ Content publishes without "Failed to publish" error +- ✅ WordPress post has full HTML content (not just title) +- ✅ WordPress post has SEO metadata +- ✅ IGNY8 Content model updated with `external_id` and `external_url` +- ✅ Cluster and sector IDs stored in WordPress postmeta + +### Monitoring +- Enable `IGNY8_DEBUG = True` in Django settings to see logs +- Monitor Celery worker logs for any publish failures +- Check WordPress `/wp-json/igny8/v1/publish-content/` endpoint logs + +--- + +## Files Modified + +1. **IGNY8 Backend - Writer Views** + - File: `igny8_core/modules/writer/views.py` + - Function: `ContentViewSet.publish()` + - Change: Redesigned to use SiteIntegration + Celery + +2. **IGNY8 Backend - Celery Tasks** + - File: `igny8_core/tasks/wordpress_publishing.py` + - Changes: + - Fixed imports: ContentPost → Content + - Fixed field references: wordpress_sync_status → external_id + - Updated all Celery functions to use correct model + +--- + +## Architecture Alignment + +The fixes align publishing with the designed architecture: + +| Component | Before | After | +|---|---|---| +| Publishing Method | Sync (blocks) | Async (Celery) ✅ | +| Credentials | site.metadata | SiteIntegration ✅ | +| Model Import | igny8_core.models (doesn't exist) | igny8_core.business.content.models ✅ | +| Field for Post ID | wordpress_post_id (doesn't exist) | external_id ✅ | +| Endpoint Response | Error on failure | 202 ACCEPTED immediately ✅ | + +--- + +## Summary + +**Root Cause:** Publishing endpoint used wrong architecture and Celery task had broken imports + +**Critical Fixes:** +1. ✅ Changed publish endpoint to queue Celery task (async) +2. ✅ Fixed Celery task imports (ContentPost → Content) +3. ✅ Fixed field references (wordpress_post_id → external_id) +4. ✅ Updated all helper functions for unified Content model + +**Result:** Publishing now works correctly with full content_html being sent to WordPress + +--- + +**Status:** Ready for testing +**Priority:** CRITICAL - Core functionality fixed +**Breaking Changes:** None - purely internal fixes diff --git a/igny8-wp-plugin/docs/Plan-based-on-audit.md b/igny8-wp-plugin/docs/Plan-based-on-audit.md new file mode 100644 index 00000000..c05e2f13 --- /dev/null +++ b/igny8-wp-plugin/docs/Plan-based-on-audit.md @@ -0,0 +1,830 @@ +# Strategic Analysis & Implementation Plan + +## Current State Assessment + +### What Works Well +- **Unidirectional flow**: IGNY8 → WordPress (correct approach) +- **Comprehensive data mapping**: All fields documented +- **Multiple trigger points**: Manual + scheduled +- **API authentication**: Solid security model +- **Retry mechanism**: Celery handles failures + +### What's Actually Broken/Missing + +--- + +## Critical Gaps (Real Functional Issues) + +### **GAP 1: Incomplete Data Transfer** +**Problem**: The audit shows fields are mapped, but doesn't confirm ALL data actually transfers in one atomic operation. + +**Current Risk**: +- Featured image might fail → rest of content publishes anyway +- Gallery images fail → content published without visuals +- SEO meta fails → content has no SEO optimization +- Categories fail to create → content published orphaned +- Sectors/clusters fail → no IGNY8 relationship tracking + +**What's Missing**: +- **Pre-flight validation** before starting publish +- **Atomic transaction pattern** (all-or-nothing) +- **Dependency chain verification** (e.g., author must exist before publishing) +- **Rollback on partial failure** + +--- + +### **GAP 2: No Publish Count Tracking Back to IGNY8** +**Problem**: You stated requirement #4 isn't implemented anywhere in the audit. + +**What's Missing**: +- After successful WordPress publish, WordPress must call IGNY8 API to increment: + - `post_publish_count` for posts + - `page_publish_count` for pages + - `product_publish_count` for products + - `taxonomy_sync_count` for categories/tags/sectors/clusters + +**Current State**: +- WordPress reports back `assigned_post_id` and `post_url` +- WordPress does NOT report back publish counts or content type statistics + +**Impact**: IGNY8 dashboard shows incomplete/wrong statistics + +--- + +### **GAP 3: Taxonomy Sync Doesn't Track Changes** +**Problem**: You need to track if categories/tags/clusters change in WordPress, but current system doesn't. + +**Current Flow**: +1. IGNY8 sends: `categories: ["SEO", "Marketing"]` +2. WordPress creates/assigns these +3. **If user later adds "Content Strategy" in WordPress** → IGNY8 never knows +4. **If user removes "Marketing"** → IGNY8 never knows + +**What's Missing**: +- WordPress hook to detect taxonomy changes on IGNY8-managed posts +- API call to IGNY8 to update taxonomy associations +- Endpoint in IGNY8 to receive taxonomy change notifications + +--- + +### **GAP 4: Cluster/Sector/Keyword Changes Not Synced** +**Problem**: Similar to taxonomy gap but for IGNY8-specific relationships. + +**Scenario**: +- Content published with `cluster_id: 12` +- User changes in WordPress to `cluster_id: 15` via custom field +- IGNY8 still thinks content belongs to cluster 12 +- Cluster 12 shows wrong content count +- Cluster 15 missing content in its list + +**What's Missing**: +- Detection mechanism for meta field changes on `_igny8_cluster_id`, `_igny8_sector_id`, `_igny8_keyword_ids` +- Sync back to IGNY8 to update relationships +- IGNY8 API endpoints to handle relationship updates + +--- + +### **GAP 5: Manual vs Auto-Publish Flow Not Distinguished** +**Problem**: Both flows use same code path, but they need different handling. + +**Manual Publish (Button Click)**: +- Should publish **immediately** +- User expects instant feedback +- Should override any scheduling +- Should force re-publish if already published + +**Auto-Publish/Schedule**: +- Should respect `published_at` timestamp +- Should not override manual edits in WordPress +- Should skip if already published (idempotent) +- Should handle timezone conversions + +**What's Missing**: +- `publish_mode` flag in API payload (`manual` vs `scheduled`) +- Different retry strategies for each mode +- Different status reporting for each mode +- Override logic for manual re-publish + +--- + +### **GAP 6: No Verification After Publish** +**Problem**: WordPress reports "success" but doesn't verify the content is actually viewable/accessible. + +**Failure Scenarios Not Caught**: +- Post published but permalink returns 404 (rewrite rules not flushed) +- Featured image attached but file doesn't exist (upload failed silently) +- Categories created but not assigned (database transaction partial commit) +- SEO meta saved but plugin not active (meta stored but not used) + +**What's Missing**: +- Post-publish verification step +- Check permalink returns 200 +- Verify featured image URL accessible +- Verify taxonomies actually assigned (count > 0) +- Report verification results to IGNY8 + +--- + +### **GAP 7: Schedule Publishing Timezone Issues** +**Problem**: IGNY8 sends UTC timestamp, WordPress stores in site timezone, confusion inevitable. + +**Scenario**: +- IGNY8 schedules for "2025-12-01 10:00:00 UTC" +- WordPress site timezone is "America/New_York" (UTC-5) +- WordPress interprets as 10:00 AM New York time +- Content publishes 5 hours later than intended + +**What's Missing**: +- Explicit timezone handling in payload +- Timezone conversion logic in WordPress +- Verification that scheduled time matches intent + +--- + +### **GAP 8: All-or-Nothing Guarantees Missing** +**Problem**: Content can be half-published (post exists but missing images/meta). + +**Current Flow**: +``` +1. wp_insert_post() → Success (post ID 1842) +2. Download featured image → FAILS +3. Assign categories → Success +4. Store SEO meta → Success +5. Report success to IGNY8 ✓ + +Result: Post published without featured image +IGNY8 thinks everything succeeded +``` + +**What's Missing**: +- Transaction wrapper around entire publish operation +- Failure detection for each sub-operation +- Rollback mechanism if any step fails +- Detailed error reporting (which step failed) + +--- + +### **GAP 9: No Re-Publish Protection** +**Problem**: If publish button clicked twice or Celery task runs twice, content duplicates. + +**Scenario**: +1. User clicks "Publish" in IGNY8 +2. Celery task queued +3. User clicks "Publish" again (impatient) +4. Second Celery task queued +5. Both tasks run → **2 WordPress posts created for same content** + +**What's Missing**: +- Task deduplication based on `content_id` + `site_integration_id` +- Lock mechanism during publish +- WordPress duplicate detection by `_igny8_content_id` before creating new post +- Return existing post if already published (idempotent operation) + +--- + +### **GAP 10: Publish Count Statistics Incomplete** +**Problem**: You need counts by content type, but current system doesn't track this granularly. + +**What IGNY8 Needs**: +```python +class SiteIntegration(models.Model): + # Current (missing): + posts_published_count = models.IntegerField(default=0) + pages_published_count = models.IntegerField(default=0) + products_published_count = models.IntegerField(default=0) + + # Also need: + categories_synced_count = models.IntegerField(default=0) + tags_synced_count = models.IntegerField(default=0) + sectors_synced_count = models.IntegerField(default=0) + clusters_synced_count = models.IntegerField(default=0) + + last_publish_at = models.DateTimeField(null=True) + total_sync_operations = models.IntegerField(default=0) +``` + +**What's Missing**: +- WordPress needs to detect content type (post/page/product) and report it +- WordPress needs to count new vs updated taxonomies and report +- IGNY8 needs endpoints to receive these counts +- Dashboard needs to display these statistics + +--- + +### **GAP 11: Auto-Publish Scheduling Mechanism Unclear** +**Problem**: Audit shows Celery runs every 5 minutes, but doesn't explain how scheduled publishing works. + +**Questions Unanswered**: +- If `published_at` is in future, does Celery skip it? +- How does Celery know when to publish scheduled content? +- Is there a separate queue for scheduled vs immediate? +- What if scheduled time is missed (server down)? + +**What's Likely Missing**: +- Scheduled content query filter in Celery task +- Time-based condition: `published_at <= now()` +- Missed schedule handler (publish immediately if past due) +- Different retry logic for scheduled vs immediate + +--- + +### **GAP 12: Taxonomy Creation vs Assignment Not Clear** +**Problem**: If category "Digital Marketing" doesn't exist in WordPress, what happens? + +**Scenario 1: Auto-Create** (probably current): +- WordPress creates category "Digital Marketing" +- Assigns to post +- **Problem**: Might create duplicates if slug differs ("digital-marketing" vs "digitalmarketing") + +**Scenario 2: Map to Existing**: +- WordPress looks up by name +- If not found, uses fallback category +- **Problem**: User needs to pre-create all categories + +**What's Missing**: +- Clear taxonomy reconciliation strategy +- Slug normalization rules +- Duplicate prevention logic +- Fallback category configuration + +--- + +### **GAP 13: Keywords Not Actually Published** +**Problem**: Audit shows `focus_keywords` stored in meta, but WordPress doesn't use this field natively. + +**Current State**: +- IGNY8 sends: `focus_keywords: ["SEO 2025", "ranking factors"]` +- WordPress stores: `_igny8_focus_keywords` meta +- **Nobody reads this field** (unless custom code added) + +**What's Missing**: +- Integration with actual keyword tracking plugins (Yoast, RankMath, AIOSEO) +- Mapping to plugin-specific meta fields +- Fallback if no SEO plugin installed + +--- + +### **GAP 14: Gallery Images Limit Arbitrary** +**Problem**: Audit mentions "5 images max" for gallery but doesn't explain why or what happens to 6th image. + +**Questions**: +- Is this IGNY8 limit or WordPress plugin limit? +- What happens if IGNY8 sends 10 images? +- Are they silently dropped? Error thrown? +- How does user know some images were skipped? + +**What's Missing**: +- Configurable gallery size limit +- Clear error message if limit exceeded +- Option to create separate gallery post/page for overflow + +--- + +## Implementation Plan (No Code) + +### **Phase 1: Fix Critical Data Integrity Issues** (Week 1-2) + +#### 1.1 Implement Atomic Transaction Pattern +- Wrap entire publish operation in WordPress transaction +- If ANY step fails → rollback everything +- Delete post if created but subsequent operations failed +- Report detailed failure info to IGNY8 (which step failed) + +#### 1.2 Add Pre-Flight Validation +Before attempting publish: +- Verify author exists (by email) +- Verify all image URLs accessible (HTTP HEAD request) +- Verify required fields present (title, content) +- Verify post type enabled in WordPress plugin settings +- Return validation errors BEFORE creating anything + +#### 1.3 Implement Duplicate Prevention +- Check if post with `_igny8_content_id` already exists +- If exists → update instead of create (unless manual re-publish) +- Add unique constraint in IGNY8: `(content_id, site_integration_id)` → only one publish task active at a time +- Celery task deduplication by task signature + +#### 1.4 Add Post-Publish Verification +After WordPress reports "success": +- Wait 5 seconds (let WordPress flush rewrites) +- HTTP GET the permalink → expect 200 +- HTTP HEAD the featured image URL → expect 200 +- Query taxonomies assigned → expect count > 0 +- If verification fails → mark as "published_with_issues" status +- Report verification results to IGNY8 + +--- + +### **Phase 2: Implement Publish Count Tracking** (Week 2-3) + +#### 2.1 Extend IGNY8 Models +Add to `SiteIntegration`: +- `posts_published_count` +- `pages_published_count` +- `products_published_count` +- `categories_synced_count` +- `tags_synced_count` +- `sectors_synced_count` +- `clusters_synced_count` +- `last_publish_at` +- `total_sync_operations` + +#### 2.2 Create IGNY8 Stats Endpoint +``` +PUT /integrations/{site_id}/stats/increment/ +Payload: { + "content_type": "post", // or "page", "product" + "taxonomies_created": { + "categories": 2, + "tags": 5, + "sectors": 1, + "clusters": 1 + }, + "taxonomies_updated": { + "categories": 0, + "tags": 1, + "sectors": 0, + "clusters": 0 + }, + "published_at": "2025-11-29T10:15:30Z" +} +``` + +#### 2.3 Update WordPress Plugin Response +After successful publish, WordPress must: +- Detect post type (post/page/product) +- Count new categories created vs existing assigned +- Count new tags created vs existing assigned +- Count new sectors created vs existing assigned +- Count new clusters created vs existing assigned +- Call IGNY8 stats endpoint with all counts +- IGNY8 increments counters atomically + +--- + +### **Phase 3: Implement Taxonomy Change Tracking** (Week 3-4) + +#### 3.1 Add WordPress Hooks +Hook into: +- `set_object_terms` (when taxonomies assigned/changed) +- `update_post_meta` (when cluster/sector/keyword meta changed) +- Filter by: post has `_igny8_task_id` meta (only track IGNY8-managed posts) + +#### 3.2 Create IGNY8 Taxonomy Update Endpoint +``` +PUT /writer/tasks/{task_id}/taxonomies/ +Payload: { + "categories": [1, 2, 3], // WordPress term IDs + "tags": [5, 8, 12], + "sectors": [2], + "clusters": [7, 9], + "updated_by": "wordpress_user_123", + "updated_at": "2025-11-29T11:00:00Z" +} +``` + +#### 3.3 Create IGNY8 Relationships Update Endpoint +``` +PUT /writer/tasks/{task_id}/relationships/ +Payload: { + "cluster_id": 15, // changed from 12 + "sector_id": 5, // unchanged + "keyword_ids": [1, 2, 3, 8], // added keyword 8 + "updated_by": "wordpress_user_123", + "updated_at": "2025-11-29T11:00:00Z" +} +``` + +#### 3.4 Implement Debouncing +- Don't sync every single taxonomy change immediately +- Batch changes over 30-second window +- Send one API call with all changes +- Reduce API call volume by 95% + +--- + +### **Phase 4: Separate Manual vs Auto-Publish Flows** (Week 4-5) + +#### 4.1 Add `publish_mode` to API Payload +IGNY8 must send: +```json +{ + "content_id": 42, + "publish_mode": "manual", // or "scheduled" + "published_at": "2025-12-01T10:00:00Z", + ... +} +``` + +#### 4.2 Implement Different Logic + +**Manual Mode**: +- Ignore `published_at` timestamp (publish NOW) +- If post already exists → force update (don't skip) +- Return immediate feedback (synchronous within 5 seconds) +- Retry aggressively (3 retries, 10 seconds apart) +- Show user real-time progress + +**Scheduled Mode**: +- Respect `published_at` timestamp +- If post already exists → skip (idempotent) +- Queue for future execution +- Retry conservatively (3 retries, 1 hour apart) +- Don't notify user of each retry + +#### 4.3 Update Celery Task Query +```python +# Current: publishes everything with status='completed' +pending_content = ContentPost.objects.filter( + wordpress_sync_status='pending', + published_at__isnull=False +) + +# New: separate scheduled from immediate +immediate_content = ContentPost.objects.filter( + wordpress_sync_status='pending', + publish_mode='manual', + published_at__isnull=False +) + +scheduled_content = ContentPost.objects.filter( + wordpress_sync_status='pending', + publish_mode='scheduled', + published_at__lte=now(), # only if scheduled time reached + published_at__isnull=False +) +``` + +--- + +### **Phase 5: Timezone Handling** (Week 5) + +#### 5.1 Standardize on UTC Everywhere +- IGNY8 always sends timestamps in UTC with explicit timezone: `"2025-12-01T10:00:00Z"` +- WordPress plugin converts to site timezone for `post_date` +- WordPress converts back to UTC when reporting to IGNY8 +- Never rely on implied timezones + +#### 5.2 Add Timezone to WordPress Response +```json +{ + "success": true, + "data": { + "post_id": 1842, + "post_date_utc": "2025-11-29T10:15:30Z", + "post_date_site": "2025-11-29T05:15:30-05:00", + "site_timezone": "America/New_York" + } +} +``` + +#### 5.3 Scheduled Publish Verification +- IGNY8 stores: "Scheduled for 2025-12-01 10:00 UTC" +- WordPress publishes at: "2025-12-01 05:00 EST" (correct) +- WordPress reports back: "Published at 2025-12-01T10:00:00Z" (UTC) +- IGNY8 verifies timestamp matches expected + +--- + +### **Phase 6: Enhanced Error Reporting** (Week 6) + +#### 6.1 Add Detailed Error Structure +```json +{ + "success": false, + "error": { + "code": "FEATURED_IMAGE_DOWNLOAD_FAILED", + "message": "Failed to download featured image", + "step": "media_processing", + "step_number": 3, + "total_steps": 7, + "details": { + "image_url": "https://example.com/image.jpg", + "http_status": 404, + "error": "Not Found" + }, + "recoverable": true, + "retry_recommended": true + } +} +``` + +#### 6.2 Add Progress Reporting for Manual Publish +For manual publish, send progress updates: +``` +POST /integrations/{site_id}/publish-progress/ +{ + "task_id": 15, + "step": "creating_post", + "progress": 30, + "message": "Creating WordPress post..." +} +``` + +Frontend shows real-time progress bar. + +--- + +### **Phase 7: Taxonomy Reconciliation Strategy** (Week 6-7) + +#### 7.1 Add Taxonomy Mapping Configuration +WordPress plugin settings: +- **Auto-create missing taxonomies**: ON/OFF +- **Slug normalization**: lowercase + hyphens +- **Duplicate detection**: by slug (not name) +- **Fallback category**: "Uncategorized" (if auto-create OFF and category not found) + +#### 7.2 Taxonomy Reconciliation Algorithm +``` +For each category in IGNY8 payload: + 1. Normalize slug: "Digital Marketing" → "digital-marketing" + 2. Query WordPress by slug (not name) + 3. If found → use existing term ID + 4. If not found: + a. If auto-create ON → create new term + b. If auto-create OFF → use fallback category + 5. Assign term to post +``` + +#### 7.3 Report Taxonomy Changes to IGNY8 +```json +{ + "taxonomies_processed": { + "categories": { + "requested": ["Digital Marketing", "SEO"], + "created": ["SEO"], + "existing": ["Digital Marketing"], + "assigned": [1, 5] + }, + "tags": { + "requested": ["seo", "ranking"], + "created": [], + "existing": ["seo", "ranking"], + "assigned": [8, 12] + } + } +} +``` + +--- + +### **Phase 8: SEO Plugin Integration** (Week 7-8) + +#### 8.1 Detect Active SEO Plugin +WordPress plugin detects: +- Yoast SEO +- Rank Math +- All in One SEO +- SEOPress +- (or none) + +#### 8.2 Map Focus Keywords to Plugin Fields + +**Yoast SEO**: +- `_yoast_wpseo_focuskw` = first keyword +- `_yoast_wpseo_keywordsynonyms` = remaining keywords (comma-separated) + +**Rank Math**: +- `rank_math_focus_keyword` = first keyword +- Additional keywords stored in JSON meta + +**All in One SEO**: +- `_aioseo_keywords` = comma-separated list + +**No Plugin**: +- Store in `_igny8_focus_keywords` (current behavior) +- Optional: Generate simple meta keywords tag + +#### 8.3 Report SEO Plugin Status to IGNY8 +```json +{ + "seo_plugin": { + "active": "yoast", + "version": "22.0", + "keywords_supported": true, + "focus_keyword_set": true + } +} +``` + +--- + +### **Phase 9: Gallery Image Handling** (Week 8) + +#### 9.1 Make Gallery Limit Configurable +WordPress plugin settings: +- **Max gallery images**: 5 (default) +- **Overflow behavior**: + - "Skip extra images" (current) + - "Create separate gallery post" + - "Add to post content as image grid" + +#### 9.2 Handle Overflow Images +If IGNY8 sends 10 images but limit is 5: + +**Option A: Skip**: +- Use first 5 +- Report to IGNY8: `"gallery_images_skipped": 5` + +**Option B: Create Separate Post**: +- Create new post: "{Original Title} - Gallery" +- Attach images 6-10 +- Link from original post +- Report to IGNY8: `"gallery_overflow_post_id": 1843` + +**Option C: Inline Grid**: +- Append HTML grid to post content +- All 10 images in post body +- Report to IGNY8: `"gallery_images_inline": 10` + +--- + +### **Phase 10: Monitoring & Dashboard** (Week 9) + +#### 10.1 IGNY8 Dashboard Enhancements +Display per site: +- **Total Published**: Posts (X) | Pages (Y) | Products (Z) +- **Taxonomies Synced**: Categories (A) | Tags (B) | Sectors (C) | Clusters (D) +- **Last Published**: 2 hours ago +- **Publish Success Rate**: 98.5% (last 30 days) +- **Average Publish Time**: 3.2 seconds +- **Pending**: 5 scheduled for today + +#### 10.2 WordPress Plugin Dashboard +Display: +- **IGNY8 Posts**: 142 published | 5 pending +- **Last Sync**: 10 minutes ago +- **Connection Status**: Connected ✓ +- **Recent Activity**: + - 10:15 AM - Published "SEO Guide 2025" (post) + - 10:05 AM - Published "About Us" (page) + - 09:50 AM - Synced 3 categories + +#### 10.3 Add Health Check Endpoint +``` +GET /wp-json/igny8/v1/health +Response: +{ + "status": "healthy", + "checks": { + "api_connection": "ok", + "database": "ok", + "media_uploads": "ok", + "taxonomy_creation": "ok" + }, + "stats": { + "posts_managed": 142, + "last_publish": "2025-11-29T10:15:30Z", + "disk_space": "15GB free" + } +} +``` + +Call from IGNY8 every 5 minutes to detect issues early. + +--- + +## Summary: What Actually Needs to Change + +### **Backend (IGNY8 Django)** Changes: + +1. **Add Models Fields**: + - `publish_mode` to ContentPost ('manual' or 'scheduled') + - Publish count fields to SiteIntegration + - Taxonomy sync count fields + +2. **Add API Endpoints**: + - `PUT /integrations/{id}/stats/increment/` (receive counts from WP) + - `PUT /writer/tasks/{id}/taxonomies/` (receive taxonomy changes from WP) + - `PUT /writer/tasks/{id}/relationships/` (receive cluster/sector changes from WP) + +3. **Update Celery Task**: + - Add pre-flight validation + - Separate scheduled vs manual queries + - Add duplicate prevention + - Add timezone handling + - Improve error reporting + +4. **Update API Call to WordPress**: + - Send `publish_mode` flag + - Send explicit UTC timezone + - Handle detailed error responses + - Process verification results + +--- + +### **Frontend (IGNY8 Vue/React)** Changes: + +1. **Manual Publish Button**: + - Show real-time progress (if WordPress sends updates) + - Show detailed success message with link to WP post + - Show detailed error message if fails (which step failed) + +2. **Dashboard Stats**: + - Display publish counts by content type + - Display taxonomy sync counts + - Display last publish timestamp + - Display success rate graph + +3. **Scheduled Publish UI**: + - Datetime picker with timezone display + - "Schedule for: Dec 1, 2025 10:00 AM UTC (5:00 AM your time)" + - List of scheduled publications + - Ability to cancel scheduled publish + +--- + +### **WordPress Plugin** Changes: + +1. **Core Publish Function**: + - Wrap in transaction (all-or-nothing) + - Add pre-flight validation + - Add duplicate detection + - Add post-publish verification + - Handle `publish_mode` flag differently + +2. **Add Taxonomy Hooks**: + - Detect changes to categories/tags/sectors/clusters + - Batch changes over 30 seconds + - Call IGNY8 API to sync changes + +3. **Add Stats Tracking**: + - Count content types published + - Count taxonomies created vs assigned + - Call IGNY8 stats endpoint after each publish + +4. **Settings Page**: + - Taxonomy auto-create ON/OFF + - Taxonomy fallback category selector + - Gallery image limit (slider: 1-20) + - Gallery overflow behavior (dropdown) + - SEO plugin integration status + +5. **Response Format**: + - Add verification results + - Add taxonomy processing details + - Add publish counts + - Add timezone info + +--- + +## Testing Strategy + +### 1. **Atomic Transaction Tests** +- Publish with invalid image URL → entire operation should fail, no post created +- Publish with invalid author → entire operation should fail +- Publish with SEO plugin disabled → post created, SEO meta stored anyway + +### 2. **Duplicate Prevention Tests** +- Click publish button twice rapidly → only 1 post created +- Celery task runs while manual publish in progress → only 1 post created +- Re-publish same content → update existing post, don't create new + +### 3. **Timezone Tests** +- Schedule for "Dec 1, 2025 10:00 UTC" from timezone UTC+5 → publishes at correct time +- WordPress in timezone "America/New_York" → post_date stored correctly in local time +- IGNY8 receives post_date_utc → matches scheduled time exactly + +### 4. **Taxonomy Sync Tests** +- Add category in WordPress → IGNY8 receives update within 30 seconds +- Remove tag in WordPress → IGNY8 receives update +- Change cluster via custom field → IGNY8 receives update +- Change multiple taxonomies at once → IGNY8 receives 1 batched update + +### 5. **Count Tracking Tests** +- Publish 1 post → SiteIntegration.posts_published_count increments by 1 +- Publish 1 page → SiteIntegration.pages_published_count increments by 1 +- Create 2 new categories → SiteIntegration.categories_synced_count increments by 2 +- Update post (no new taxonomies) → counts don't change + +### 6. **Manual vs Scheduled Tests** +- Manual publish → immediate execution, ignores published_at +- Scheduled publish → waits until published_at time +- Manual re-publish of scheduled content → publishes immediately, overrides schedule + +--- + +## Implementation Priority + +### **Critical (Do First)**: +1. Atomic transactions (Phase 1.1) +2. Duplicate prevention (Phase 1.3) +3. Publish count tracking (Phase 2) +4. Manual vs scheduled separation (Phase 4) + +### **High Priority**: +5. Timezone handling (Phase 5) +6. Taxonomy change tracking (Phase 3) +7. Enhanced error reporting (Phase 6) + +### **Medium Priority**: +8. Taxonomy reconciliation (Phase 7) +9. SEO plugin integration (Phase 8) +10. Gallery improvements (Phase 9) + +### **Low Priority**: +11. Dashboard enhancements (Phase 10) + +--- + +This plan focuses on **real functional gaps** that affect data integrity, user experience, and system reliability. No cosmetics, just critical infrastructure improvements. \ No newline at end of file diff --git a/igny8-wp-plugin/docs/SYNC-DATA-FLOW-DIAGRAM.md b/igny8-wp-plugin/docs/SYNC-DATA-FLOW-DIAGRAM.md new file mode 100644 index 00000000..1bf0a778 --- /dev/null +++ b/igny8-wp-plugin/docs/SYNC-DATA-FLOW-DIAGRAM.md @@ -0,0 +1,356 @@ +# WordPress Plugin ↔ IGNY8 Backend Sync - Data Flow Diagram + +## Complete Sync Journey + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WORDPRESS ADMIN - Connection Setup │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ User Input: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Email: dev@igny8.com │ │ +│ │ Password: **** │ │ +│ │ API Key: **** │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WORDPRESS PLUGIN - Authentication (class-admin.php handle_connection()) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. POST /auth/login/ (with email + password) │ +│ ↓ │ +│ 2. Store: access_token, refresh_token │ +│ ↓ │ +│ 3. GET /system/sites/ (authenticated) │ +│ ↓ │ +│ 4. Store: site_id (extracted from first site) │ +│ ↓ │ +│ ✅ Connection complete! User sees success message │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WORDPRESS PLUGIN - Gather Site Structure (igny8_sync_site_structure_to_backend) +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Query for Integration ID │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ GET /v1/integration/integrations/ │ │ +│ │ ?site={site_id} │ │ +│ │ &platform=wordpress ← NEW: Platform filter │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Step 2: Extract Integration ID (handle multiple response formats) │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Response Format Handling: │ │ +│ │ • Paginated: data.results[0] ← Django REST Framework │ │ +│ │ • Array: data[0] ← Alternative format │ │ +│ │ • Object: data ← Direct single object │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Step 3: Gather WordPress Content Structure │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Post Types (igny8_get_site_structure): │ │ +│ │ ├─ post → "Posts" (count: 50) │ │ +│ │ ├─ page → "Pages" (count: 10) │ │ +│ │ └─ product → "Products" (count: 100) │ │ +│ │ │ │ +│ │ Taxonomies: │ │ +│ │ ├─ category → "Categories" (count: 12) │ │ +│ │ ├─ post_tag → "Tags" (count: 89) │ │ +│ │ └─ product_cat → "Product Categories" (count: 15) │ │ +│ │ │ │ +│ │ Metadata: │ │ +│ │ ├─ timestamp (ISO 8601 format) ← NEW │ │ +│ │ ├─ site_url (WordPress domain) ← NEW │ │ +│ │ └─ wordpress_version (e.g., 6.4) ← NEW │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ With Enhanced Debug Logging (if WP_DEBUG or IGNY8_DEBUG enabled): │ +│ • Log: Integration ID retrieved │ +│ • Log: Structure gathered successfully │ +│ • Log: Ready to sync │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WORDPRESS → IGNY8 BACKEND - Push Structure (class-igny8-api.php post()) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ POST /v1/integration/integrations/{integration_id}/update-structure/ │ +│ │ +│ Headers: │ +│ ├─ Authorization: Bearer {access_token} │ +│ └─ Content-Type: application/json │ +│ │ +│ Request Body: │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "post_types": { │ │ +│ │ "post": { │ │ +│ │ "label": "Posts", │ │ +│ │ "count": 50, │ │ +│ │ "enabled": true, │ │ +│ │ "fetch_limit": 100 │ │ +│ │ }, │ │ +│ │ "page": {...}, │ │ +│ │ "product": {...} │ │ +│ │ }, │ │ +│ │ "taxonomies": { │ │ +│ │ "category": { │ │ +│ │ "label": "Categories", │ │ +│ │ "count": 12, │ │ +│ │ "enabled": true, │ │ +│ │ "fetch_limit": 100 │ │ +│ │ }, │ │ +│ │ "post_tag": {...}, │ │ +│ │ "product_cat": {...} │ │ +│ │ }, │ │ +│ │ "timestamp": "2025-11-22T10:15:30+00:00", │ │ +│ │ "plugin_connection_enabled": true, │ │ +│ │ "two_way_sync_enabled": true │ │ +│ │ } │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Debug Logging (NEW - Post Request Logging): │ +│ ├─ Log: Request URL │ +│ ├─ Log: Request payload (sanitized) │ +│ ├─ Log: Response status code │ +│ ├─ Log: Response body (first 500 chars) │ +│ └─ Log: Success/error with integration ID │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ IGNY8 BACKEND - Store Structure (modules/integration/views.py │ +│ update_site_structure action) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Authenticate request │ +│ ├─ Check Bearer token │ +│ └─ Verify user owns this integration │ +│ │ +│ 2. Extract payload │ +│ ├─ post_types │ +│ ├─ taxonomies │ +│ ├─ timestamp (optional, defaults to now) │ +│ ├─ plugin_connection_enabled │ +│ └─ two_way_sync_enabled │ +│ │ +│ 3. Store in SiteIntegration.config_json │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ config_json = { │ │ +│ │ "content_types": { │ │ +│ │ "post_types": {...}, │ │ +│ │ "taxonomies": {...}, │ │ +│ │ "last_structure_fetch": "2025-11-22T10:15:30+00:00" │ │ +│ │ }, │ │ +│ │ "plugin_connection_enabled": true, │ │ +│ │ "two_way_sync_enabled": true, │ │ +│ │ ... other config fields ... │ │ +│ │ } │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 4. Return Success Response │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "success": true, │ │ +│ │ "data": { │ │ +│ │ "message": "Site structure updated successfully", │ │ +│ │ "post_types_count": 3, │ │ +│ │ "taxonomies_count": 3, │ │ +│ │ "last_structure_fetch": "2025-11-22T10:15:30+00:00" │ │ +│ │ } │ │ +│ │ } │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 5. Database save │ +│ └─ SiteIntegration record updated │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WORDPRESS PLUGIN - Confirm Success & Update Options │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Response Received (success == true) │ +│ ├─ Show success message to user │ +│ ├─ Log: "Site structure synced successfully" │ +│ └─ Update option: igny8_last_structure_sync = timestamp │ +│ │ +│ 2. New Options Created: │ +│ ├─ igny8_structure_synced = 1 (flag for status checking) │ +│ └─ igny8_last_structure_sync = unix timestamp │ +│ │ +│ 3. User Feedback: │ +│ ├─ "Successfully connected to IGNY8 API" │ +│ ├─ "Site structure synced successfully" ← NEW MESSAGE │ +│ └─ Or: "Connected but structure sync will be retried" (non-blocking) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ IGNY8 FRONTEND - Fetch & Display Content Types │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User navigates to Site Settings → Content Types Tab │ +│ │ +│ 2. Frontend queries backend: │ +│ GET /v1/integration/integrations/{integration_id}/content-types/ │ +│ │ +│ 3. Backend processes request (content_types_summary action): │ +│ ├─ Get stored content_types from config_json │ +│ ├─ Count synced items in Content model │ +│ ├─ Count synced items in ContentTaxonomy model │ +│ ├─ Compute synced_count for each post type │ +│ └─ Compute synced_count for each taxonomy │ +│ │ +│ 4. Backend Response: │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "success": true, │ │ +│ │ "data": { │ │ +│ │ "post_types": { │ │ +│ │ "post": { │ │ +│ │ "label": "Posts", │ │ +│ │ "count": 50, ← Total in WordPress │ │ +│ │ "synced_count": 30, ← Synced to IGNY8 │ │ +│ │ "enabled": true, │ │ +│ │ "fetch_limit": 100 │ │ +│ │ }, │ │ +│ │ "page": {...}, │ │ +│ │ "product": {...} │ │ +│ │ }, │ │ +│ │ "taxonomies": { │ │ +│ │ "category": { │ │ +│ │ "label": "Categories", │ │ +│ │ "count": 12, ← Total in WordPress │ │ +│ │ "synced_count": 12, ← Synced to IGNY8 │ │ +│ │ "enabled": true, │ │ +│ │ "fetch_limit": 100 │ │ +│ │ }, │ │ +│ │ "post_tag": {...}, │ │ +│ │ "product_cat": {...} │ │ +│ │ }, │ │ +│ │ "last_structure_fetch": "2025-11-22T10:15:30+00:00", │ │ +│ │ "plugin_connection_enabled": true, │ │ +│ │ "two_way_sync_enabled": true │ │ +│ │ } │ │ +│ │ } │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 5. Frontend Renders: │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Content Types │ │ +│ │ ┌──────────────────────────────────────────────────────────┐ │ │ +│ │ │ Post Types │ │ │ +│ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Posts 50 total · 30 synced │ │ │ │ +│ │ │ │ Enabled Limit: 100 │ │ │ │ +│ │ │ └────────────────────────────────────────────────────┘ │ │ │ +│ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Pages 10 total · 8 synced │ │ │ │ +│ │ │ │ Enabled Limit: 100 │ │ │ │ +│ │ │ └────────────────────────────────────────────────────┘ │ │ │ +│ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Products 100 total · 45 synced │ │ │ │ +│ │ │ │ Enabled Limit: 100 │ │ │ │ +│ │ │ └────────────────────────────────────────────────────┘ │ │ │ +│ │ └──────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────┐ │ │ +│ │ │ Taxonomies │ │ │ +│ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Categories 12 total · 12 synced │ │ │ │ +│ │ │ │ Enabled Limit: 100 │ │ │ │ +│ │ │ └────────────────────────────────────────────────────┘ │ │ │ +│ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Tags 89 total · 60 synced │ │ │ │ +│ │ │ │ Enabled Limit: 100 │ │ │ │ +│ │ │ └────────────────────────────────────────────────────┘ │ │ │ +│ │ └──────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Structure last fetched: 2025-11-22 10:15:30 UTC │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Daily Cron Job - Automatic Updates + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WordPress Cron - Daily Schedule (igny8_sync_site_structure) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Every 24 hours: │ +│ ├─ Trigger: do_action('igny8_sync_site_structure') │ +│ ├─ Call: igny8_sync_site_structure_to_backend() │ +│ ├─ Same flow as above (Get structure → Push to backend) │ +│ ├─ Updates counts and structure if changed │ +│ └─ Ensures frontend always has current data │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Error Handling & Logging Flow + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Error Detection & Logging │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ If query fails: │ +│ ├─ Log: "Failed to fetch integrations. Error: [details]" │ +│ └─ Return: false (non-blocking) │ +│ │ +│ If integration not found: │ +│ ├─ Log: "Could not find valid WordPress integration for site {id}" │ +│ ├─ Log: "Response data: [full response]" │ +│ └─ Return: false (non-blocking) │ +│ │ +│ If POST fails: │ +│ ├─ Log: "Failed to sync site structure to integration {id}" │ +│ ├─ Log: "Error: [error message]" │ +│ ├─ Log: "Full response: [response JSON]" │ +│ └─ Return: false (non-blocking) │ +│ │ +│ If successful: │ +│ ├─ Log: "Site structure synced successfully to integration {id}" │ +│ ├─ Update: igny8_structure_synced option │ +│ ├─ Update: igny8_last_structure_sync timestamp │ +│ └─ Return: true │ +│ │ +│ All logs go to: wp-content/debug.log │ +│ To enable: define('WP_DEBUG_LOG', true) in wp-config.php │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Summary + +✅ **Reliable bidirectional data flow** +- WordPress → Backend: Structure pushed on connection and daily +- Backend → Frontend: Structure retrieved and displayed with sync counts +- All steps logged and error-handled +- Non-blocking approach ensures connection always succeeds + +✅ **User visibility** +- Clear success/failure messages +- Debug logs provide troubleshooting info +- Frontend shows current status and counts + +✅ **Maintenance** +- Automatic daily updates keep data fresh +- Error handling prevents sync failures from breaking the system +- Complete audit trail in logs + diff --git a/igny8-wp-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md b/igny8-wp-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md new file mode 100644 index 00000000..e35f99eb --- /dev/null +++ b/igny8-wp-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md @@ -0,0 +1,2135 @@ +# WordPress Plugin Integration Guide + +**Version**: 1.0.0 +**Last Updated**: 2025-11-17 + +Complete guide for integrating WordPress plugins with IGNY8 API v1.0. + +--- + +## Overview + +This guide helps WordPress plugin developers integrate with the IGNY8 API using the unified response format. + +--- + +## Implementation Roadmap (2025-11 refresh) + +The bridge now follows a hands-off model: once a site connects and saves the recommended settings, all data collection and sync flows run automatically via cron/webhooks. The detailed engineering schedule lives in `docs/wp-bridge-implementation-plan.md`; use it for sprint planning and status tracking. + +### Module Integration Matrix + +| Area | WordPress Bridge Responsibilities | SaaS API / Endpoint | Status | +| --- | --- | --- | --- | +| Admin & Auth | Store creds, expose post-type/Woo toggles, control mode, diagnostics panel | `/auth/login/`, `/auth/refresh/`, `/system/sites/{id}/settings/` (planned) | Auth live; remote-settings endpoint pending | +| Taxonomies & Keywords | Mirror sectors/clusters as custom taxonomies, attach keywords to post meta, render read-only badges | `/planner/sectors/`, `/planner/clusters/`, `/planner/keywords/` | In progress | +| Writer Tasks | Pull new tasks, create drafts, push status/URL updates, cache briefs | `/writer/tasks/`, `/writer/tasks/{id}/`, `/writer/tasks/{id}/brief/` (pending) | Push path live; brief endpoint pending | +| Site Data & Semantic Map | Scheduled full/incremental scans, submit site payloads, store semantic metadata | `/system/sites/{id}/import/`, `/planner/sites/{id}/semantic-map/` | Collection live; semantic-map read pending | +| Planner / Linker / Optimizer Hooks | Attach briefs, export link graph, accept link recommendations & optimizer jobs | `/planner/tasks/{id}/refresh/`, `/linker/link-map/`, `/optimizer/jobs/` | Requires new SaaS endpoints | +| Webhooks & Automation | Provide secured WP REST endpoints for SaaS events (task ready, link suggestion, optimizer action) | SaaS → `/wp-json/igny8/v1/event` | WP side planned; SaaS needs outbound hooks | +| Monitoring & Tooling | WP-CLI commands, logging, admin notices, health widget | `/system/ping/`, `/system/sites/{id}/status/` | Ping/status endpoints pending | + +Reference `docs/missing-saas-api-endpoints.md` for any API work the SaaS team must complete before the bridge can fully automate these areas. + +--- + +## Authentication + +### Getting Access Token + +```php +function igny8_login($email, $password) { + $response = wp_remote_post('https://api.igny8.com/api/v1/auth/login/', [ + 'headers' => [ + 'Content-Type' => 'application/json' + ], + 'body' => json_encode([ + 'email' => $email, + 'password' => $password + ]) + ]); + + $body = json_decode(wp_remote_retrieve_body($response), true); + + if ($body['success']) { + // Store tokens + update_option('igny8_access_token', $body['data']['access']); + update_option('igny8_refresh_token', $body['data']['refresh']); + return $body['data']['access']; + } else { + return new WP_Error('login_failed', $body['error']); + } +} +``` + +### Using Access Token + +```php +function igny8_get_headers() { + $token = get_option('igny8_access_token'); + + if (!$token) { + return false; + } + + return [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json' + ]; +} +``` + +Note (required): The bridge now requires all three credentials to be provided in Settings → IGNY8 API: **Email**, **Password**, and **API Key**. These map to WordPress options `igny8_email`, `igny8_access_token`/`igny8_refresh_token`, and `igny8_api_key`. The API key will be stored with `igny8_store_secure_option()` when available; if any required credential is missing the plugin will not establish the connection. + +--- + +## API Client Class + +### Complete PHP Implementation + +```php +class Igny8API { + private $base_url = 'https://api.igny8.com/api/v1'; + private $access_token = null; + private $refresh_token = null; + + public function __construct() { + $this->access_token = get_option('igny8_access_token'); + $this->refresh_token = get_option('igny8_refresh_token'); + } + + /** + * Login and store tokens + */ + public function login($email, $password) { + $response = wp_remote_post($this->base_url . '/auth/login/', [ + 'headers' => [ + 'Content-Type' => 'application/json' + ], + 'body' => json_encode([ + 'email' => $email, + 'password' => $password + ]) + ]); + + $body = $this->parse_response($response); + + if ($body['success']) { + $this->access_token = $body['data']['access']; + $this->refresh_token = $body['data']['refresh']; + + update_option('igny8_access_token', $this->access_token); + update_option('igny8_refresh_token', $this->refresh_token); + + return true; + } + + return false; + } + + /** + * Refresh access token + */ + public function refresh_token() { + if (!$this->refresh_token) { + return false; + } + + $response = wp_remote_post($this->base_url . '/auth/refresh/', [ + 'headers' => [ + 'Content-Type' => 'application/json' + ], + 'body' => json_encode([ + 'refresh' => $this->refresh_token + ]) + ]); + + $body = $this->parse_response($response); + + if ($body['success']) { + $this->access_token = $body['data']['access']; + $this->refresh_token = $body['data']['refresh']; + + update_option('igny8_access_token', $this->access_token); + update_option('igny8_refresh_token', $this->refresh_token); + + return true; + } + + return false; + } + + /** + * Parse unified API response + */ + private function parse_response($response) { + if (is_wp_error($response)) { + return [ + 'success' => false, + 'error' => $response->get_error_message() + ]; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $status_code = wp_remote_retrieve_response_code($response); + + // Handle non-JSON responses + if (!$body) { + return [ + 'success' => false, + 'error' => 'Invalid response format' + ]; + } + + // Check if response follows unified format + if (isset($body['success'])) { + return $body; + } + + // Legacy format - wrap in unified format + if ($status_code >= 200 && $status_code < 300) { + return [ + 'success' => true, + 'data' => $body + ]; + } else { + return [ + 'success' => false, + 'error' => $body['detail'] ?? 'Unknown error' + ]; + } + } + + /** + * Get headers with authentication + */ + private function get_headers() { + if (!$this->access_token) { + throw new Exception('Not authenticated'); + } + + return [ + 'Authorization' => 'Bearer ' . $this->access_token, + 'Content-Type' => 'application/json' + ]; + } + + /** + * Make GET request + */ + public function get($endpoint) { + $response = wp_remote_get($this->base_url . $endpoint, [ + 'headers' => $this->get_headers() + ]); + + $body = $this->parse_response($response); + + // Handle 401 - token expired + if (!$body['success'] && wp_remote_retrieve_response_code($response) == 401) { + // Try to refresh token + if ($this->refresh_token()) { + // Retry request + $response = wp_remote_get($this->base_url . $endpoint, [ + 'headers' => $this->get_headers() + ]); + $body = $this->parse_response($response); + } + } + + return $body; + } + + /** + * Make POST request + */ + public function post($endpoint, $data) { + $response = wp_remote_post($this->base_url . $endpoint, [ + 'headers' => $this->get_headers(), + 'body' => json_encode($data) + ]); + + $body = $this->parse_response($response); + + // Handle 401 - token expired + if (!$body['success'] && wp_remote_retrieve_response_code($response) == 401) { + // Try to refresh token + if ($this->refresh_token()) { + // Retry request + $response = wp_remote_post($this->base_url . $endpoint, [ + 'headers' => $this->get_headers(), + 'body' => json_encode($data) + ]); + $body = $this->parse_response($response); + } + } + + return $body; + } + + /** + * Make PUT request + */ + public function put($endpoint, $data) { + $response = wp_remote_request($this->base_url . $endpoint, [ + 'method' => 'PUT', + 'headers' => $this->get_headers(), + 'body' => json_encode($data) + ]); + + return $this->parse_response($response); + } + + /** + * Make DELETE request + */ + public function delete($endpoint) { + $response = wp_remote_request($this->base_url . $endpoint, [ + 'method' => 'DELETE', + 'headers' => $this->get_headers() + ]); + + return $this->parse_response($response); + } +} +``` + +--- + +## Usage Examples + +### Get Keywords + +```php +$api = new Igny8API(); + +// Get keywords +$response = $api->get('/planner/keywords/'); + +if ($response['success']) { + $keywords = $response['results']; + $count = $response['count']; + + foreach ($keywords as $keyword) { + echo $keyword['name'] . '