Jake Vanderwerf
7 days ago 46d681c6b825d21b3f698d793c4e630c687d90ad
inc/integrations/Integrations.php
@@ -2,10 +2,12 @@
namespace JVBase\integrations;
use Exception;
use JVBase\managers\CacheManager;
use JVBase\managers\UploadManager;
use JVBase\meta\MetaManager;
use JVBase\managers\Cache;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\managers\ErrorHandler;
use JVBase\registrar\helpers\AddIntegrationFields;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Post;
use WP_REST_Request;
@@ -35,6 +37,7 @@
   protected array $apiEndpoints = [];   // Valid endpoint paths for this service
   protected string $apiVersion = '';     // API version string (e.g., 'v2', '2024-01-01')
   protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
   /**
    * OAuth Configuration
@@ -61,7 +64,7 @@
    */
   protected array $credentials = [];      // Service credentials (API keys, tokens, etc.)
   protected ?int $userID = null;          // User context for user-specific integrations
   private bool $token_refresh_attempted = false;  // Circuit breaker for token refresh
   protected array $fields = [];       // The fields to generate that become credentials
   protected array $advanced = [];  // The fields that are optional settings
@@ -90,12 +93,13 @@
      'test_connection' => 'Test Connection',
   ];
   protected array $allowedContent = [];
   /**
    * Caching Configuration
    */
   protected ?string $cacheName = null;
   protected CacheManager $cache;
   protected Cache $cache;
   protected array $cacheStrategy = [
      'aggressive' => 3600,  // 1 hour for stable data (e.g., profile info)
      'moderate' => 300,     // 5 minutes for semi-dynamic data (e.g., posts)
@@ -130,8 +134,8 @@
      'update' => false,     // Can update already-shared posts
      'delete' => false,     // Can remove posts from the service
   ];
   protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []]
   protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []]
   protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
   protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
   protected array $contentTypes = [];   // Integration's available content types. Set by child classes' getContentTypes
   protected bool $has_content = false; // Whether integration has content that can sync
   /**
@@ -166,7 +170,7 @@
   {
      $this->cacheName = $this->cacheName ?: $this->service_name;
      $this->userID = $userID;
      $this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl);
      $this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl);
      // Load error stats from cache
      $this->loadErrorStats();
@@ -257,10 +261,6 @@
      if (!empty($this->credentials['expires_at'])) {
         $expires_at = intval($this->credentials['expires_at']);
         if ($expires_at <= time()) {
            // Token expired, try to refresh
            if (!empty($this->credentials['refresh_token'])) {
               return $this->refreshOAuthToken();
            }
            return false;
         }
      }
@@ -298,12 +298,10 @@
      $postTypes = get_option($key, false);
      if (!$postTypes) {
         $postTypes = [];
         // Get from JVB_CONTENT
         foreach (JVB_CONTENT as $type => $config) {
            if (array_key_exists('integrations', $config) && array_key_exists($this->service_name, $config['integrations'])) {
               $postTypes[] = $type;
         foreach (Registrar::getRegistered('post') as $registrar) {
            $registrar = Registrar::getInstance($registrar);
            if ($registrar->hasIntegration($this->service_name)) {
               $postTypes[] = $registrar->getSlug();
            }
         }
@@ -321,11 +319,10 @@
      if (!$taxonomies) {
         // Combine both content and taxonomy filtering
         $taxonomies = [];
         // Get from JVB_TAXONOMY (content taxonomies)
         foreach (JVB_TAXONOMY as $type => $config) {
            if (jvbCheck('is_content', $config) &&
               isset($config['integrations'][$this->service_name])) {
               $taxonomies[] = $type;
         foreach (Registrar::getFeatured('is_content', 'term') as $type) {
            $registrar = Registrar::getInstance($type);
            if ($registrar->hasIntegration($this->service_name)) {
               $taxonomies[] = $registrar->getSlug();
            }
         }
@@ -433,7 +430,6 @@
      } else {
         $result =  $this->$method();
      }
      error_log('Action result: '.print_r($result, true));
      if (is_wp_error($result)) {
         return [
            'success' => false,
@@ -654,9 +650,7 @@
      }
      try {
         error_log('Credentials to save: '.print_r($this->credentials, true));
         if ($this->isOAuthService && !$this->hasOAuthCredentials()){
            error_log('Just saving credentials, we don\'t have OAuth setup yet...');
            //If this is an OAuth service, we might only be saving the app credentials first
            $result = true;
         } else {
@@ -675,7 +669,7 @@
   protected function clearCache():array
   {
      $success = $this->cache->clear();
      $success = $this->cache->flush();
      return [
         'success'   => $success,
      ];
@@ -764,6 +758,9 @@
      array  $options = []
   ): array|WP_Error
   {
      if (!$this->is_healthy) {
         return new WP_Error('unhealthy', 'Connection marked unhealthy. Skipping fetch');
      }
      $this->ensureInitialized();
      if (!$this->isSetUp()){
         $this->logError('Connection not setup for '.$this->service_name, [
@@ -777,24 +774,21 @@
         return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
      }
      // Debug: Check if credentials are loaded
      error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
      error_log('With Credentials: '.print_r($this->credentials, true));
      $attempt = 0;
      $lastError = null;
      while ($attempt < $this->maxRetries) {
         try {
            $this->logDebug('[Integrations] Making request to '.$this->service_name);
//          $this->logDebug('[Integrations] Making request to '.$this->service_name);
            $result = $this->executeRequest($method, $endpoint, $data, $baseKey, $options);
            if (!$result) {
               return new WP_Error('Request Error');
            }
            $this->recordRequest($method, $endpoint);
            $this->logDebug('[Integrations]', [
               'response' => $result
            ]);
//          $this->logDebug('[Integrations]', [
//             'response' => $result
//          ]);
            // Reset error stats on success
            $this->resetErrorStats();
            return $result;
@@ -806,7 +800,9 @@
            // Retry with backoff for server errors
            if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
               sleep($this->retryDelays[$attempt - 1] ?? 5);
               $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
               $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
               usleep($delay + $jitter);
            } else {
               break;
            }
@@ -841,16 +837,16 @@
         return null;
      }
      $this->logDebug("$method request to: $url: ".print_r($args, true));
//    $this->logDebug("$method request to: $url: ".print_r($args, true));
      // Standard WordPress HTTP API
      // Use appropriate WordPress HTTP function
      // Make the request
      $response = match($method) {
         'GET' => wp_remote_get($url, $args),
         'POST' => wp_remote_post($url, $args),
         'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
         default => null
      };
      if (!$response) {
         $this->logError("Unsupported HTTP method $method for $this->service_name");
         return null;
@@ -864,9 +860,42 @@
      $response_code = wp_remote_retrieve_response_code($response);
      $body = wp_remote_retrieve_body($response);
      // Handle 401 - try to refresh token and retry once
      if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
         // Avoid infinite retry loop - only retry once
         static $retry_count = 0;
         if ($retry_count === 0) {
            $retry_count++;
//          $this->logDebug('Got 401, attempting token refresh...');
            if ($this->refreshOAuthToken()) {
//             $this->logDebug('Token refreshed successfully, retrying request...');
               // Rebuild request args with new token
               $args = $this->buildRequestArgs($method, $data, $options);
               // Retry the request
               $response = match($method) {
                  'GET' => wp_remote_get($url, $args),
                  'POST' => wp_remote_post($url, $args),
                  'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
                  default => null
               };
               if ($response && !is_wp_error($response)) {
                  $response_code = wp_remote_retrieve_response_code($response);
                  $body = wp_remote_retrieve_body($response);
               }
            }
            $retry_count = 0; // Reset for next request
         }
      }
      if ($response_code >= 400) {
         $this->handleApiError($response_code, $body, $endpoint);
      }
      $decoded = json_decode($body, true);
      if (json_last_error() !== JSON_ERROR_NONE) {
         return ['raw_response' => $body];
@@ -888,25 +917,41 @@
      bool   $force = false
   ): ?array
   {
      $cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
      $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
      $cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey);
      $ttl = is_int($cacheStrategy)
         ? max(0, $cacheStrategy)
         : ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl);
      if (!$force && $ttl > 0) {
         $cached = $this->cache->get($cacheKey);
         if ($cached !== false) {
            $this->logDebug("Cache hit for: $cacheKey");
//          $this->logDebug("Cache hit for: $cacheKey");
            return $cached;
         }
      }
      $result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
      if ($result && $ttl > 0) {
      // Only cache successful responses (not WP_Error and not error objects)
      if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
         $this->cache->set($cacheKey, $result, $ttl);
      }
      return $result;
   }
   /**
    * Check if response contains an error
    * Override in child classes for service-specific error detection
    */
   protected function isErrorResponse(array $response): bool
   {
      // Common error patterns across APIs
      return isset($response['error'])
         || isset($response['errors'])
         || isset($response['error_description']);
   }
   /**
    * POST request
@@ -1168,14 +1213,10 @@
   public function handleAjaxResponse()
   {
      error_log('Ajax Response: '.print_r($_GET, true));
      $code = $_GET['code'];
      $state =  $_GET['state'];
      error_log('OAuth Callback - Code: ' . $code);
      error_log('OAuth Callback - State: ' . $state);
      $state_parts = explode('|', $state);
      $state_key = $state_parts[0] ?? '';
@@ -1183,16 +1224,13 @@
      $user_id = ($user_id === 0) ? null : $user_id;
      $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
      error_log('Service: '.print_r($this->service_name, true));
      $state_data = get_transient('oauth_state_' . $state_key);
      error_log('State Data: '.print_r($state_data, true));
      if (!$state_data || $state_data['service'] !== $this->service_name) {
         wp_die('Invalid state parameter', 'OAuth Error');
      }
      // Delete the transient to prevent reuse
      delete_transient('oauth_state_' . $state_key);
      error_log('Return URL: '.print_r($return_url, true));
      // Handle error from OAuth provider
      if (array_key_exists('error', $_GET)) {
         $error_description = $_GET['error_description'] ?? 'Authorization denied';
@@ -1341,10 +1379,33 @@
      }
      if (!empty($this->credentials)) {
         if ($this->isOAuthService && $this->hasOAuthCredentials() && !$this->isOAuthValid()) {
            $this->logDebug('OAuth token expired, attempting refresh');
            if (!$this->refreshOAuthToken()) {
               $this->logError('Failed to refresh OAuth token');
         if ($this->isOAuthService && $this->hasOAuthCredentials()) {
            // Check if token is expired first
            if (!$this->isOAuthValid()) {
               // Only attempt refresh once per request
               if (!$this->token_refresh_attempted) {
                  $this->token_refresh_attempted = true;
//                $this->logDebug('OAuth token expired, attempting refresh');
                  if (!$this->refreshOAuthToken()) {
                     $this->logError('Failed to refresh expired OAuth token - stopping execution');
                     // Token refresh failed - DO NOT continue making API requests
                     return;
                  }
               } else {
                  // Already attempted refresh in this request
//                $this->logDebug('Token refresh already attempted, skipping');
                  return;
               }
            }
            // Check if we should proactively refresh (before expiry)
            elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) {
               $this->token_refresh_attempted = true;
//             $this->logDebug('OAuth token should be refreshed proactively');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to proactively refresh OAuth token');
                  // Not critical - token is still valid, so continue
               }
            }
         }
         $this->initialize();
@@ -1367,6 +1428,7 @@
      // Switch context
      $this->userID = $user_id;
      $this->credentials = [];
      $this->resetTokenRefreshFlag();  // ADD THIS LINE
      $this->ensureInitialized();
   }
@@ -1564,12 +1626,7 @@
         $params = $this->addOAuthParams($params);
      }
      $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
      // Debug log for troubleshooting
      error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
      return $auth_url;
      return $this->oauth['authorize'] . '?' . http_build_query($params);
   }
   /**
@@ -1744,12 +1801,7 @@
         'redirect_uri' => $this->getRedirectUri()
      ];
      // Use a custom endpoint key for OAuth (not part of regular API)
      // We need to handle this specially since OAuth endpoints are different
      $oauth_endpoint = $this->oauth['token'];
      // Make the request using the centralized method
      // This automatically includes rate limiting and error handling
      $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
      if (is_wp_error($response)) {
@@ -1762,10 +1814,13 @@
      // Parse response
      if (isset($response['access_token'])) {
         $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
         return [
            'access_token' => $response['access_token'],
            'refresh_token' => $response['refresh_token'] ?? '',
            'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
            'expires_in' => $expires_in,
            'expires_at' => time() + $expires_in, // Calculate expiry timestamp
            'token_type' => $response['token_type'] ?? 'Bearer',
            'merchant_id' => $response['merchant_id'] ?? '',
            'scope' => $response['scope'] ?? ''
@@ -1786,6 +1841,75 @@
   }
   /**
    * Check if token should be proactively refreshed
    * Different from isOAuthValid() which checks if token is actually expired
    */
   protected function shouldRefreshToken(): bool
   {
      if (!$this->isOAuthService || $this->refresh_interval === 0) {
         return false;
      }
      // If no expiry info, we can't proactively refresh
      if (empty($this->credentials['expires_at'])) {
         return false;
      }
      $expires_at = intval($this->credentials['expires_at']);
      $time_until_expiry = $expires_at - time();
      // Refresh if we're within the refresh interval window
      return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
   }
   /**
    * Get time until token refresh is recommended
    * Useful for displaying in admin UI
    */
   public function getTimeUntilRefresh(): ?int
   {
      if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
         return null;
      }
      $expires_at = intval($this->credentials['expires_at']);
      $refresh_at = $expires_at - $this->refresh_interval;
      $time_until_refresh = $refresh_at - time();
      return max(0, $time_until_refresh);
   }
   /**
    * Get token freshness status
    * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
    */
   public function getTokenStatus(): string
   {
      if (!$this->isOAuthService) {
         return 'not_oauth';
      }
      if (empty($this->credentials['access_token'])) {
         return 'no_token';
      }
      if (empty($this->credentials['expires_at'])) {
         return 'no_expiry_info';
      }
      $expires_at = intval($this->credentials['expires_at']);
      $now = time();
      if ($expires_at <= $now) {
         return 'expired';
      }
      if ($this->shouldRefreshToken()) {
         return 'should_refresh';
      }
      return 'fresh';
   }
   /**
    * Refresh OAuth token
    */
   protected function refreshOAuthToken(): bool
@@ -1794,7 +1918,6 @@
         return false;
      }
      // Build refresh request data
      $request_data = [
         'client_id' => $this->credentials['client_id'],
         'client_secret' => $this->credentials['client_secret'],
@@ -1802,12 +1925,24 @@
         'grant_type' => 'refresh_token'
      ];
      // Use centralized OAuth request method
      $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
      if (is_wp_error($response)) {
         $this->logError('Failed to refresh Square token', [
            'error' => $response->get_error_message()
         $error_message = $response->get_error_message();
         if (str_contains($error_message, 'invalid_grant')) {
            $this->logError('OAuth refresh token is invalid - user must re-authorize', [
               'error' => $error_message
            ], 'critical');
            // Mark unhealthy immediately
            $this->error_stats['consecutive_errors'] = $this->error_threshold;
            $this->is_healthy = false;
            $this->saveErrorStats();
         }
         $this->logError('Failed to refresh OAuth token for '.$this->service_name, [
            'error' => $error_message
         ]);
         return false;
      }
@@ -1816,7 +1951,7 @@
         $this->credentials['access_token'] = $response['access_token'];
         $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
         // Note: Square returns the SAME refresh token
         // Note: Some services return the SAME refresh token
         if (isset($response['refresh_token'])) {
            $this->credentials['refresh_token'] = $response['refresh_token'];
         }
@@ -1946,20 +2081,11 @@
    */
   protected function mapFieldsToService(int $postID, array $mapping): array
   {
      $meta_manager = new MetaManager($postID, 'post');
      $post = get_post($postID);
      $meta_manager = Meta::forPost($postID);
      $service_data = [];
      foreach ($mapping as $wp_field => $service_field) {
         $value = null;
         // Check if it's a post field
         if (property_exists($post, $wp_field)) {
            $value = $post->$wp_field;
         } else {
            // It's a meta field
            $value = $meta_manager->getValue($wp_field);
         }
         $value = $meta_manager->get($wp_field);
         if ($value !== null && $value !== '') {
            $this->setNestedValue($service_data, $service_field, $value);
@@ -1998,52 +2124,54 @@
    */
   public function handleSavePost(int $postID, WP_Post $post, bool $update): void
   {
      error_log('Testing For Save Post');
      if (jvbNoSaveIt($postID, $post)) {
         error_log('Excluded by jvbNoSaveIt');
      if (!$postID || $postID === 0) {
         return;
      }
      $postType = jvbNoBase($post->post_type);
      if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
      if (wp_is_post_revision($postID)) return;
      $registrar = Registrar::getInstance($postType);
      if (!$registrar){
         return;
      }
      if (empty($this->syncPostTypes)) {
         error_log('No Syncable post types');
         return;
      }
      $config = JVB_CONTENT[jvbNoBase($post->post_type)]??null;
      if (!$config) {
         error_log('No Config set');
         return;
      }
      $settings = $config['integrations'][$this->service_name]??null;
      $settings = $registrar->hasIntegration($this->service_name)??null;
      if (!$settings) {
         error_log('No settings');
         return;
      }
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings){
         return;
      }
      $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]);
      error_log('Fields to check: '.print_r($fields, true));
      if (!$fields['share_to_'.$this->service_name]) {
         return;
      }
      $isShared = isset($fields["_{$this->service_name}_item_id"]);
      if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) {
         error_log('Do not keep synced, not syncing with '.$this->service_name);
         return;
      }
      if ($post->post_status !== 'publish' && !$isShared) {
         error_log('Not published and not already shared');
         return;
      }
      error_log('Sending to integration for processing...');
      $this->handleTheSavePost($postID, $post, $update, $settings);
   }
   protected function getSyncFields(int $postID, string $type, array $additional = []):array
   {
      $meta = new MetaManager($postID, $type);
      $meta = new Meta($postID, $type);
      $fieldsToCheck = [
         'share_to_' . $this->service_name,
         '_keep_synced_' . $this->service_name,
@@ -2126,13 +2254,13 @@
      if (!in_array($noBase, $this->syncTaxonomies)) {
         return;
      }
      // Check if taxonomy is content-type
      $config = JVB_TAXONOMY[$noBase] ?? null;
      if (!$config || !($config['is_content'] ?? false)) {
      $registrar = Registrar::getInstance($noBase);
      if (!$registrar->hasFeature('is_content')) {
         return;
      }
      $settings = $config['integrations'][$this->service_name] ?? null;
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings) {
         return;
      }
@@ -2509,11 +2637,6 @@
      ];
      $this->is_healthy = true;
      $this->saveErrorStats();
      $this->logDebug('Integration health manually reset', [
         'reset_by' => get_current_user_id(),
         'reset_time' => time()
      ]);
   }
   /**
@@ -2648,9 +2771,6 @@
      // Check for duplicate processing (idempotency)
      if ($this->isWebhookProcessed($payload)) {
         $this->logDebug('Webhook already processed', [
            'webhook_id' => $this->extractWebhookId($payload)
         ]);
         return true; // Return true to prevent retries
      }
@@ -2812,7 +2932,7 @@
         return '';
      }
      $meta = new MetaManager($this->userID, 'integrations');
      $meta = Meta::forOptions($this->userID.'_integrations');
      $is_connected = $this->isSetUp();
      $credentials = $this->getCredentials();
@@ -2825,7 +2945,7 @@
      ?>
      <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>"
          data-service="<?php echo esc_attr($this->service_name); ?>">
         <div class="header row btw">
         <div class="header row x-btw">
            <h3><?php echo esc_html($this->title); ?></h3>
            <div class="setup">
               <?php if ($is_connected): ?>
@@ -2889,7 +3009,7 @@
                  $config['value'] = $credentials[$name]??'';
                  $config['autocomplete'] = 'off';
                  $config['base'] = $this->service_name.'_';
                  $meta->render('form', $name, $config);
                  echo Form::render($name, '', $config);
               }
            }
            if ($this->handleWebhooks) {
@@ -2920,7 +3040,7 @@
                     $config['value'] = $credentials[$name]??'';
                     $config['base'] = $this->service_name.'_';
                     $config['autocomplete'] = 'off';
                     $meta->render('form', $name, $config);
                     Form::render($name,null, $config);
                  }
                  ?>
               </details>
@@ -2936,7 +3056,7 @@
            }
            ?>
         </div>
         <div class="actions row btw wrap">
         <div class="actions row x-btw wrap">
            <?php
            foreach ($this->buttons as $action => $label) {
               if (!$is_connected && $action !== 'save_credentials') {
@@ -2946,7 +3066,7 @@
               switch ($action) {
                  case 'save_credentials':
                     $title = $label;
                     $label = jvbIcon('save');
                     $label = jvbIcon('floppy-disk');
                     break;
                  case 'clear_credentials':
                     $title = $label;
@@ -2981,7 +3101,7 @@
      }
      $credentials = $this->getCredentials();
      $hasCredentials = $this->hasOAuthCredentials();
      $returnURL = (is_admin()) ? :get_the_permalink();
      $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
      ?>
      <details <?= $hasCredentials?' open':''?>>
@@ -3063,7 +3183,6 @@
      // Verify nonce
      $nonce_field = 'jvb_integration_nonce_' . $service;
      $nonce_action = 'jvb_integration_save_' . $service;
      error_log('handleAdminPost: '.print_r($_POST, true));
      if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) {
         wp_die('Security check failed');
      }
@@ -3160,7 +3279,7 @@
      if (empty($types)) {
         return;
      }
      $meta = new MetaManager($this->userID, 'integrations');
      $meta = Meta::forOptions($this->userID.'_integrations');
      ?>
      <form>
         <h1><?= $this->title?> Defaults:</h1>
@@ -3172,16 +3291,19 @@
            $config['base'] = $this->service_name.'_';
            $config['autocomplete'] = 'off';
            $meta->render('form', $name, $config);
            echo Form::render($name, null, $config);
         }
         foreach ($this->syncPostTypes as $type) {
            $config = JVB_CONTENT[$type];
            $registrar = Registrar::getInstance($type);
            $icon = $registrar->getIcon();
            $icon = $icon === '' ? jvbDefaultIcon() : $icon;
            ?>
            <details>
               <summary><?= jvbIcon($config['icon']) ?><?= $config['singular']?> Defaults</summary>
               <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary>
               <?php
               $fields = new \JVBase\registry\providers\IntegrationFieldProvider();
               $fields = $fields->getIntegrationFields($this->service_name, $config);
               $fields = new AddIntegrationFields($this->service_name);
               $fields = $fields->getIntegrationFields();
               foreach($fields as $name=>$c) {
                  $c['required'] = false;
                  if ($c['type'] === 'number') {
@@ -3192,7 +3314,7 @@
                     $c['hint'] = $c['description'];
                     unset($c['description']);
                  }
                  $meta->render('form', $name, $c);
                  echo Form::render($name, null, $c);
               }
               ?>
            </details>
@@ -3222,17 +3344,16 @@
      $enabled = get_option($key);
      if (!$enabled) {
         $enabled = [];
         foreach (array_merge(JVB_CONTENT, JVB_TAXONOMY) as $content => $config) {
            if (!array_key_exists('integrations', $config)) {
         foreach (Registrar::getRegistered() as $registrar) {
            $registrar = Registrar::getInstance($registrar);
            if (!$registrar->hasIntegration($this->service_name)) {
               continue;
            }
            if (!array_key_exists($this->service_name, $config['integrations'])) {
            $type = $registrar->getIntegration($this->service_name)->getContent_type();
            if (!$type) {
               continue;
            }
            if (!array_key_exists('content_type', $config['integrations'][$this->service_name])) {
               continue;
            }
            $type = $config['integrations'][$this->service_name]['content_type'];
            if (!in_array($type, $enabled)) {
               $enabled[] = $type;
            }
@@ -3400,4 +3521,26 @@
         throw new Exception('Failed to save JPEG image');
      }
   }
   /**
    * Reset token refresh attempt flag
    * Called automatically when switching users
    */
   protected function resetTokenRefreshFlag(): void
   {
      $this->token_refresh_attempted = false;
   }
   public function getAllowedContent():array
   {
      return $this->allowedContent;
   }
   /**
    * Used by JVBase\registrar\helpers\AddIntegrationFields.php
    * @return array
    */
   public function getAdditionalFields(?string $content_type = null):array
   {
      return [];
   }
}