Jake Vanderwerf
2026-05-12 457c329237f97069063e641b10f384a52d584f21
inc/managers/queue/executors/ContentExecutor.php
@@ -1,9 +1,9 @@
<?php
namespace JVBase\managers\queue\executors;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use JVBase\managers\queue\{Executor, Operation, Progress, Result, Storage};
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use Exception;
if (!defined('ABSPATH')) {
@@ -18,34 +18,30 @@
{
   private const HANDLED_TYPES = [
      'content_update',
      'batch_creation',
//    'batch_creation',
   ];
   private int $userId;
   private string $postType;
   private array $fields = [];
   private array $timelineSharedFields = [];
   private array $timelineUniqueFields = [];
   public function execute(Operation $operation, Progress $progress): Result
   {
      $this->userId = $operation->userId;
      if (!in_array($operation->type, self::HANDLED_TYPES)) {
         throw new Exception("ContentExecutor cannot handle type: {$operation->type}");
      }
      $this->userId = $operation->userId;
      error_log('Executing ContentExecutor.php');
      try {
         $data = $operation->requestData;
         $result = match($operation->type) {
         return match($operation->type) {
            'content_update'  => $this->processContentUpdate($operation, $data, $progress),
            'batch_creation'  => $this->processBatchCreation($operation, $data, $progress),
            default           => throw new Exception("Unknown type: {$operation->type}")
         };
         return $result;
      } catch (Exception $e) {
         JVB()->error()->log(
            '[ContentExecutor]:execute',
@@ -71,135 +67,71 @@
   private function processContentUpdate(Operation $operation, array $data, Progress $progress): Result
   {
      $posts = $data['posts'] ?? [];
      error_log('Processing Content Update: '.print_r($posts, true));
      if (empty($posts)) {
         return new Result(
            outcome: 'failed',
            outcome: 'success',
            result: ['message' => 'No posts to update']
         );
      }
      $results = [];
      $results = [
         'errors' => [],
         'success'   => [],
         'newPosts'  => [],
         'timelineParents' => [],
         'timelineStatus'  => [],
         'timelineSharedFields'  => [],
      ];
      $errors = [];
      $updateTimelineOrder = [];
      foreach ($posts as $id => $postData) {
         try {
            $content = $postData['content'] ?? '';
            // New post creation
            if (str_starts_with((string)$id, 'new')) {
               $newId = wp_insert_post([
                  'post_author' => $this->userId,
                  'post_type'   => jvbCheckBase($content),
                  'post_title'  => $postData['post_title'] ?? '',
                  'post_status' => $postData['status'] ?? 'draft',
               ]);
               if (!$newId || is_wp_error($newId)) {
                  $progress->failItem($id, 'Could not create post');
                  continue;
               }
               $this->savePostFields($newId, $postData);
               $results[$id] = [
                  'success' => true,
                  'new_id' => $newId,
                  'processed_fields' => array_keys($postData)
               ];
               if (Features::forContent($content)->has('is_timeline')) {
                  $this->updateTimelineLatestDate($newId);
               }
               $progress->advance(1);
               continue;
            $content = $postData['content'] ?? false;
            if (!$content) continue;
            $registrar = Registrar::getInstance($content);
            switch ($registrar->getType()) {
               case 'post':
                  $results = $this->handlePost($id, $postData, $registrar, $results, $progress);
                  break;
               case 'term':
                  $results = $this->handleTerm($id, $postData, $registrar, $results, $progress);
                  break;
               case 'user':
                  $results = $this->handleUser($id, $postData, $registrar, $results, $progress);
                  break;
            }
            // Existing post update
            if (!$this->verifyOwnership((int)$id)) {
               $progress->failItem($id, 'No permission to modify this post');
               $errors[$id] = 'No permission';
               continue;
            }
            // Check if this is a timeline post
            $isTimeline = Features::forContent($content)->has('is_timeline');
            if ($isTimeline) {
               $post = get_post((int)$id);
               $parentId = $post->post_parent;
               $isParent = ($parentId === 0);
               // Track timeline reordering only if date changed
               if (array_key_exists('post_date', $postData)) {
                  $timelineRoot = $isParent ? (int)$id : $parentId;
                  if (!in_array($timelineRoot, $updateTimelineOrder)) {
                     $updateTimelineOrder[] = $timelineRoot;
                  }
               }
               // Update shared fields if this is the parent
               if ($isParent) {
                  $this->initTimelineFields($content);
                  $sharedFieldsUpdated = array_filter($postData, function($key) {
                     return in_array($key, $this->timelineSharedFields);
                  }, ARRAY_FILTER_USE_KEY);
                  if (!empty($sharedFieldsUpdated)) {
                     $this->updateSharedFields((int)$id, $sharedFieldsUpdated);
                  }
               }
            }
            $this->processPostUpdate((int)$id, $postData);
            if (Features::forContent($content)->has('is_timeline')
               && array_key_exists('post_date', $postData)) {
               $post = get_post((int)$id);
               $parentId = $post->post_parent === 0 ? (int)$id : $post->post_parent;
               $this->updateTimelineLatestDate($parentId);
            }
            $results[$id] = [
               'success' => true,
               'processed_fields' => array_keys($postData)
            ];
            $progress->advance(1);
         } catch (Exception $e) {
            $progress->failItem($id, $e->getMessage());
            $errors[$id] = $e->getMessage();
            $results[$id] = [
               'success' => false,
               'error' => $e->getMessage()
            ];
            $results['errors'][$id] = $e->getMessage();
         }
      }
      if (!empty($updateTimelineOrder)) {
         $processedParents = []; // Track to avoid duplicate processing
      error_log('Final Results: '.print_r($results, true));
         foreach ($updateTimelineOrder as $oldParentID) {
            if (in_array($oldParentID, $processedParents)) continue;
            $actualParentId = $this->reorderTimelineByDate($oldParentID);
            $processedParents[] = $actualParentId;
            // If parent changed, mark the new parent as processed too
            if ($actualParentId !== $oldParentID) {
               $processedParents[] = $oldParentID;
            }
      try {
         if (!empty($results['timelineSharedFields'])) {
            $this->checkSharedFields($results['timelineSharedFields']);
         }
         if (!empty($results['timelineStatus'])) {
            $this->handleTimelineStatusChange($results['timelineStatus']);
         }
         if (!empty($results['timelineParents'])) {
            $this->maybeReorderTimelines($results['timelineParents']);
         }
      } catch (Exception $e) {
         $results['errors'][] = $e->getMessage();
      }
      // Send notification
      if (jvbSiteHasNotifications()) {
         JVB()->notification()->addNotification(
            $this->userId,
            'content_update_complete',
            null,
            'Content updates completed!'
         );
      }
//    if (jvbSiteHasNotifications()) {
//       JVB()->notification()->addNotification(
//          $this->userId,
//          'content_update_complete',
//          null,
//          'Content updates completed!'
//       );
//    }
      $outcome = 'success';
      if (!empty($errors)) {
@@ -208,431 +140,504 @@
      return new Result(
         outcome: $outcome,
         result: [
            'posts' => $results,
            'errors' => $errors,
            'updated_count' => count(array_filter($results, fn($r) => $r['success'] ?? false)),
            'failed_count' => count($errors)
         ]
         result: $results,
      );
   }
   private function processPostUpdate(int $postId, array $postData): void
   {
      $content = $postData['content'] ?? '';
      // Handle status changes
      if (isset($postData['post_status'])) {
         switch ($postData['post_status']) {
            case 'publish':
               if (user_can($this->userId, 'manage_options') || user_can($this->userId, 'skip_moderation')) {
                  wp_update_post(['ID' => $postId, 'post_status' => 'publish']);
               }
               unset($postData['post_status']);
               break;
            case 'draft':
               wp_update_post(['ID' => $postId, 'post_status' => 'draft']);
               unset($postData['post_status']);
               break;
            case 'trash':
               wp_trash_post($postId);
               return;
            case 'delete':
               wp_delete_post($postId, true);
               return;
         }
      }
      // Save all fields via MetaManager (handles post fields too)
      $this->savePostFields($postId, $postData);
   }
   private function updateSharedFields(int $parentId, array $sharedFields): void
   {
      // Get all posts in timeline
      $children = get_posts([
         'post_type' => get_post_type($parentId),
         'post_parent' => $parentId,
         'post_status' => ['publish', 'draft'],
         'posts_per_page' => -1,
         'fields' => 'ids'
      ]);
      $allPostIds = array_merge([$parentId], $children);
      // Apply shared fields to all posts
      foreach ($allPostIds as $timelinePostId) {
         $meta = new MetaManager($timelinePostId, 'post');
         $meta->setAll($sharedFields);
      }
   }
   private function reorderTimelineByDate(int $parentId): int
   {
      $parent = get_post($parentId);
      if (!$parent) return $parentId;
      // Get all posts in this timeline (parent + children)
      $children = get_posts([
         'post_type' => get_post_type($parentId),
         'post_parent' => $parentId,
         'post_status' => ['publish', 'draft'],
         'posts_per_page' => -1,
         'orderby' => 'date',
         'order' => 'ASC'
      ]);
      // Combine and sort by post_date
      $allPosts = array_merge([$parent], $children);
      usort($allPosts, function($a, $b) {
         return strtotime($a->post_date) <=> strtotime($b->post_date);
      });
      $newParent = $allPosts[0];
      $actualParentId = $newParent->ID; // Track the actual parent
      // If parent changed, restructure
      if ($newParent->ID !== $parentId) {
         wp_update_post([
            'ID' => $newParent->ID,
            'post_parent' => 0,
            'menu_order' => 0
         ]);
         wp_update_post([
            'ID' => $parentId,
            'post_parent' => $newParent->ID
         ]);
         foreach ($allPosts as $index => $post) {
            if ($index === 0) continue;
            wp_update_post([
               'ID' => $post->ID,
               'post_parent' => $newParent->ID,
               'menu_order' => $index
            ]);
            $this->getOrCreateTerm($post->ID, (string)$index, 'number');
         }
      } else {
         // Just update menu_order
         foreach ($allPosts as $index => $post) {
            if ($index === 0) continue;
            wp_update_post([
               'ID' => $post->ID,
               'menu_order' => $index
            ]);
            $this->getOrCreateTerm($post->ID, (string)$index, 'number');
         }
      }
      // Calculate and set timeline taxonomy (time since previous post)
      $previousPost = null;
      foreach ($allPosts as $index => $post) {
         if ($index === 0) {
            // Parent post - no timeline term (it's the baseline)
            wp_set_object_terms($post->ID, [], BASE . 'timeline', false);
            $previousPost = $post;
            continue;
         }
         $timelineTerm = $this->calculateTimelineTerm($previousPost, $post);
         if ($timelineTerm) {
            $this->getOrCreateTerm($post->ID, $timelineTerm, 'timeline');
         }
         $previousPost = $post;
      }
      // Update latest_date AFTER reordering with the actual parent
      $this->updateTimelineLatestDate($actualParentId);
      return $actualParentId; // Return the actual parent ID
   }
   private function updateTimelineLatestDate(int $parentId): void
   {
      $parent = get_post($parentId);
      if (!$parent) return;
      // Get all posts in timeline
      $children = get_posts([
         'post_type' => get_post_type($parentId),
         'post_parent' => $parentId,
         'post_status' => ['publish', 'draft'],
         'posts_per_page' => -1,
         'orderby' => 'date',
         'order' => 'DESC', // Get newest first
         'fields' => 'ids'
      ]);
      // Count: parent + children
      $number = count($children) + 1;
      $allPostIds = array_merge([$parentId], $children);
      // Get all timestamps
      $timestamps = array_map(function($id) {
         $post = get_post($id);
         return $post ? strtotime($post->post_date) : 0;
      }, $allPostIds);
      $latestTimestamp = max($timestamps);
      // Update both meta fields
      update_post_meta($parentId, BASE . 'number', $number);
      update_post_meta($parentId, BASE . 'latest_date', $latestTimestamp);
   }
   private function calculateTimelineTerm(\WP_Post $previousPost, \WP_Post $currentPost): ?string
   {
      $previousDate = strtotime($previousPost->post_date);
      $currentDate = strtotime($currentPost->post_date);
      if (!$previousDate || !$currentDate || $currentDate <= $previousDate) {
         return null;
      }
      // Calculate difference in days
      $daysDiff = floor(($currentDate - $previousDate) / (60 * 60 * 24));
      // Convert to weeks
      $weeks = floor($daysDiff / 7);
      // If less than 16 weeks, use weeks
      if ($weeks < 16) {
         if ($weeks === 0) {
            return null; // Same week, no term
         }
         return $weeks === 1 ? '1 Week' : $weeks . ' Weeks';
      }
      // 16+ weeks, calculate months
      // Using actual month calculation rather than weeks/4
      $previousDateTime = new \DateTime($previousPost->post_date);
      $currentDateTime = new \DateTime($currentPost->post_date);
      $interval = $previousDateTime->diff($currentDateTime);
      $months = ($interval->y * 12) + $interval->m;
      if ($months === 0) {
         // Edge case: technically less than a full month but 16+ weeks
         return $weeks . ' Weeks';
      }
      return $months === 1 ? '1 Month' : $months . ' Months';
   }
   private function getOrCreateTerm(int $postID, string $termName, string $taxonomy): void
   {
      $taxonomy = jvbCheckBase($taxonomy);
      $term = get_term_by('name', $termName, $taxonomy);
      if (!$term) {
         $result = wp_insert_term($termName, $taxonomy);
         if (is_wp_error($result)) {
            return;
         }
         $termID = $result['term_id'];
      } else {
         $termID = $term->term_id;
      }
      if ($termID) {
         wp_set_object_terms($postID, [$termID], $taxonomy, false);
      }
   }
   private function savePostFields(int $postId, array $postData): bool
   {
      $content = $postData['content'] ?? '';
      $fields = jvbGetFields($content);
      $fields = Registrar::getFieldsFor($content);
      $allowedFields = array_filter($postData, function ($key) use ($fields) {
         return array_key_exists($key, $fields);
      }, ARRAY_FILTER_USE_KEY);
      //Remove values that are already saved
      $check = Meta::forPost($postId)->getAll(array_keys($allowedFields));
      error_log('Stored values: '.print_r($check, true));
      $allowedFields = array_filter($allowedFields, function ($key) use ($allowedFields, $check) {
         return $allowedFields[$key] !== $check[$key];
      }, ARRAY_FILTER_USE_KEY);
      if (empty($allowedFields)) {
         return true;
      }
      $meta = new MetaManager($postId, 'post');
      return $meta->setAll($allowedFields);
      return Meta::forPost($postId)
         ->setAll($allowedFields);
   }
   // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
   // Batch Creation
   // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
   private function processBatchCreation(Operation $operation, array $data, Progress $progress): Result
   private function saveTermFields(int $termId, array $data): bool
   {
      $this->postType = BASE . $data['content'];
      $content = $data['content'] ?? '';
      error_log('Saving term fields: '.print_r($data, true));
      $fields = Registrar::getFieldsFor($content);
      // Get upload results from dependency
      $uploadOpId = $operation->id . '_upload';
      $images = JVB()->queue()->get($uploadOpId)?->result ?? null;
      $allowedFields = array_filter($data, function ($key) use ($fields) {
         return array_key_exists($key, $fields);
      }, ARRAY_FILTER_USE_KEY);
      if (!$images) {
         return new Result(
            outcome: 'failed',
            result: ['message' => 'No upload results found']
         );
      //Remove values that are already saved
      $check = Meta::forTerm($termId)->getAll(array_keys($allowedFields));
      error_log('Stored values: '.print_r($check, true));
      $allowedFields = array_filter($allowedFields, function ($value, $key) use ($check) {
         error_log('Sent value: '.print_r($value, true));
         error_log('Stored Value: '.print_r($check[$key], true));
         return $value !== $check[$key];
      }, ARRAY_FILTER_USE_BOTH);
      if (empty($allowedFields)) {
         return true;
      }
      $results = [];
      error_log('Allowed fields: '.print_r($allowedFields, true));
      if ($data['mode'] === 'selection') {
         $results = $this->createFromSelection($operation, $data, $images, $progress);
      } else {
         $results = $this->createFromDirect($operation, $data, $images, $progress);
      }
      return new Result(
         outcome: !empty($results) ? 'success' : 'failed',
         result: $results
      );
      return Meta::forTerm($termId)
         ->setAll($allowedFields);
   }
   private function createFromSelection(Operation $operation, array $data, array $images, Progress $progress): array
   private function saveUserFields(int $userId, array $data): bool
   {
      $results = [];
      $content = $data['content'] ?? '';
      $fields = Registrar::getFieldsFor($content);
      foreach ($images as $group => $files) {
         $settings = json_decode($data['files_data'][$group] ?? '{}');
      $allowedFields = array_filter($data, function ($key) use ($fields) {
         return array_key_exists($key, $fields);
      }, ARRAY_FILTER_USE_KEY);
         if (($settings->type ?? '') === 'group') {
            $postId = $this->createGroupPost($operation, $data, $files, $settings);
         } else {
            $postId = $this->createIndividualPosts($operation, $data, $files);
         }
      //Remove values that are already saved
      $check = Meta::forUser($userId)->getAll(array_keys($allowedFields));
      $allowedFields = array_filter($allowedFields, function ($key) use ($allowedFields, $check) {
         return $allowedFields[$key] !== $check[$key];
      }, ARRAY_FILTER_USE_KEY);
         if ($postId) {
            $results = array_merge($results, (array)$postId);
         }
         $progress->advance(1);
      if (empty($allowedFields)) {
         return true;
      }
      return $results;
      return Meta::forUser($userId)
         ->setAll($allowedFields);
   }
   private function createFromDirect(Operation $operation, array $data, array $images, Progress $progress): array
   {
      $results = [];
      foreach ($images as $img) {
         $postId = wp_insert_post([
            'post_type'   => $this->postType,
            'post_title'  => $this->generatePostTitle($data['content']),
            'post_status' => 'draft',
            'post_author' => $operation->userId,
         ]);
         if ($postId && !is_wp_error($postId)) {
            set_post_thumbnail($postId, $img['attachment_id']);
            $results[] = $postId;
         }
         $progress->advance(1);
      }
      return $results;
   }
   private function createGroupPost(Operation $operation, array $data, array $files, object $settings): ?int
   {
      $featuredIndex = $settings->metadata->featuredFile ?? 0;
      $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']);
      $postId = wp_insert_post([
         'post_type'   => $this->postType,
         'post_title'  => $title,
         'post_status' => 'draft',
         'post_author' => $operation->userId,
      ]);
      if (!$postId || is_wp_error($postId)) {
         return null;
      }
      // Set featured image
      set_post_thumbnail($postId, $files[$featuredIndex]['attachment_id']);
      // Remaining files go to gallery
      unset($files[$featuredIndex]);
      if (!empty($files)) {
         $meta = new MetaManager($postId, 'post');
         $ids = array_column($files, 'attachment_id');
         $meta->updateValue('gallery', implode(',', $ids));
      }
      return $postId;
   }
   private function createIndividualPosts(Operation $operation, array $data, array $files): array
   {
      $results = [];
      foreach ($files as $img) {
         $title = $this->generatePostTitle($data['content']);
         $postId = wp_insert_post([
            'post_type'   => $this->postType,
            'post_title'  => $title,
            'post_slug'    => sanitize_title($title),
            'post_status' => 'draft',
            'post_author' => $operation->userId,
         ]);
         if ($postId && !is_wp_error($postId)) {
            set_post_thumbnail($postId, $img['attachment_id']);
            $results[] = $postId;
         }
      }
      return $results;
   }
   // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
   // Helpers
   // â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
   private function initTimelineFields(string $content): void
   {
      $content = jvbNoBase($content);
      if (!Features::forContent($content)->has('is_timeline')) {
         return;
      }
      $config = Features::getConfig($content);
      $this->fields = $config['fields'] ?? [];
      // Shared fields (apply to all posts)
      $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
         return !isset($field['for_all']) || $field['for_all'] === false;
      }));
      array_unshift($this->timelineSharedFields, 'post_thumbnail', 'post_title', 'post_status');
      // Unique fields (per-entry)
      $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
         return isset($field['for_all']) && $field['for_all'] === true;
      }));
   }
   private function verifyOwnership(int $postId): bool
   {
      $post = get_post($postId);
      return $post && (int)$post->post_author === $this->userId;
   }
   private function generatePostTitle(string $content): string
   /*************************************************************
    * TIMELINE HELPERS
    *************************************************************/
   protected function maybeReorderTimelines(array $parentIDs):void {
      foreach ($parentIDs as $parentId) {
         try {
            $this->maybeReorderTimeline((int)$parentId);
         } catch (Exception $e) {
            error_log("Timeline reorder failed for parent {$parentId}: " . $e->getMessage());
         }
      }
   }
   protected function getTimelinePosts(int $parentID):array
   {
      $username = get_user_meta($this->userId, 'first_name', true);
      $link = get_user_meta($this->userId, BASE . 'link', true);
      $city = function_exists('jvbArtistCity') ? jvbArtistCity($link) : '';
      $parent = get_post($parentID);
      if (!$parent) {
         return [];
      }
      return ucfirst($content) . ' by ' . ($city ? "$city artist " : '') . $username;
      $children = get_children([
         'post_parent' => $parentID,
         'posts_per_page' => -1,
         'post_status' => ['publish', 'draft'],
      ]);
      if (count($children) === 0) {
         return [];
      }
      $allPosts = array_merge([$parent], $children);
      // Sort by post_date
      usort($allPosts, function($a, $b) {
         return strtotime($a->post_date) <=> strtotime($b->post_date);
      });
      return $allPosts;
   }
   protected function maybeReorderTimeline(int $parentID):void
   {
//    clean_post_cache($parentID);
      $parent = get_post($parentID);
      if (!$parent) {
         return;
      }
      $allPosts = $this->getTimelinePosts($parentID);
      if (empty($allPosts)) {
         return;
      }
      // Check if order changed
      $needsReorder = false;
      foreach ($allPosts as $index => $post) {
         if ($index === 0 && $post->ID !== $parent->ID) {
            $needsReorder = true;
            break;
         }
         if ($index > 0 && (int)$post->menu_order !== $index) {
            $needsReorder = true;
            break;
         }
      }
      if (!$needsReorder) {
         // Just recalculate timelines without reordering
         $this->recalculateTimelines($allPosts);
         return;
      }
      // Handle parent swap if needed
      $newParent = $allPosts[0];
      if ($newParent->ID !== $parent->ID) {
         $this->swapTimelineParent($parent, $newParent, $allPosts);
      } else {
         // Just update menu orders and timelines
         foreach ($allPosts as $index => $post) {
            if ($index === 0) continue; // Skip parent
            $success = jvb_update_post([
               'ID' => $post->ID,
               'post_parent'  => $newParent->ID,
               'menu_order' => $index
            ]);
         }
         $this->recalculateTimelines($allPosts);
      }
   }
   private function recalculateTimelines(array $posts): void
   {
      $previousPost = null;
      $latestTimestamp = 0;
      $lastKey = array_key_last($posts);
      foreach ($posts as $index => $post) {
         $meta = Meta::forPost($post->ID);
         if ($index === 0) {
            $meta->set('timeline', '');
            $previousPost = $post;
            continue; // Parent has no timeline
         }
         // Calculate timeline from previous post
         if ($previousPost) {
            $timeline = $this->calculateTimeline($previousPost, $post);
            if ($timeline) {
               $termId = $this->getOrCreateTerm($timeline, 'timeline');
               if ($termId) {
                  $success = $meta->set('timeline', $termId);
               }
            }
         }
         if ($lastKey === $index) {
            $latestTimestamp = strtotime($post->post_date);
         }
         $previousPost = $post;
      }
      // Update parent's latest_date
      if ($latestTimestamp > 0) {
         $success = update_post_meta($posts[0]->ID, BASE . 'latest_date', $latestTimestamp);
      }
   }
   private function calculateTimeline(\WP_POST $previous, \WP_POST $current): ?string
   {
      $previousDate = strtotime($previous->post_date);
      $currentDate = strtotime($current->post_date);
      if (!$previousDate || !$currentDate || $currentDate <= $previousDate) {
         return null;
      }
      $daysDiff = floor(($currentDate - $previousDate) / (60*60*24));
      $weeks = floor($daysDiff / 7);
      if ($weeks === 0) {
         return 'Less than 1 Week';
      }
      if ($weeks < 16) {
         return $weeks === 1 ? '1 Week' : $weeks . ' Weeks';
      }
      $previousDateTime = new \DateTime($previous->post_date);
      $currentDateTime = new \DateTime($current->post_date);
      $interval = $previousDateTime->diff($currentDateTime);
      $months = ($interval->y * 12) + $interval->m;
      if ($months === 0) {
         return $weeks . ' Weeks';
      }
      return ($months === 1) ? '1 Month' : $months . ' Months';
   }
   private function swapTimelineParent(\WP_Post $oldParent, \WP_Post $newParent, array $allPosts): void
   {
      // Swap titles and content
      $originalTitle = $oldParent->post_title;
      $originalSlug = $oldParent->post_name;
      $originalContent = $oldParent->post_content;
      $updateParent = jvb_update_post([
         'ID' => $oldParent->ID,
         'post_title' => 'Treatment',
         'post_name' => sanitize_title('Treatment ' . $newParent->ID),
         'post_content' => '',
      ]);
      $updateNewParent = jvb_update_post([
         'ID' => $newParent->ID,
         'post_title' => $originalTitle,
         'post_name' => $originalSlug,
         'post_content' => $originalContent,
         'post_parent' => 0,
         'menu_order' => 0
      ]);
      // Clear timeline taxonomy from new parent
      wp_set_object_terms($newParent->ID, [], BASE . 'timeline', false);
      // Update all other posts to new parent
      foreach ($allPosts as $index => $post) {
         if ($index === 0) continue; // Skip new parent
         $title = $post->post_title;
         if (str_starts_with($title, 'Treatment #')) {
            $title = 'Treatment #' . $index;
         }
         $childUpdate = jvb_update_post([
            'ID' => $post->ID,
            'post_title' => $title,
            'post_parent' => $newParent->ID,
            'menu_order' => $index,
         ]);
      }
      // Recalculate timelines for all posts
      $this->recalculateTimelines($allPosts);
   }
   private function getOrCreateTerm(string $termName, string $taxonomy): ?int
   {
      $taxonomy = jvbCheckBase($taxonomy);
      $term = get_term_by('name', $termName, $taxonomy);
      if (!$term) {
         $result = wp_insert_term($termName, $taxonomy);
         if (is_wp_error($result)) {
            return null;
         }
         return $result['term_id'];
      }
      return $term->term_id;
   }
   protected function checkSharedFields(array $fields): void
   {
      foreach ($fields as $parentID => $shared) {
         $meta = Meta::forPost($parentID);
         $values = $meta->getAll($shared);
         $children = get_children([
            'post_parent' => $parentID,
            'posts_per_page' => -1,
            'fields' => 'ids',
         ]);
         if (empty($children)) {
            continue;
         }
         foreach ($children as $child) {
            Meta::forPost($child)->setAll($values);
         }
      }
   }
   protected function handleTimelineStatusChange(array $updates):void
   {
      $updates = array_filter($updates, function ($status) {
         return in_array($status, ['trash', 'delete', 'publish', 'draft']);
      });
      foreach ($updates as $parentID => $status) {
         $children = get_children([
            'post_parent'  => $parentID,
            'posts_per_page' => -1,
            'fields' => 'ids'
         ]);
         if (!empty($children)) {
            foreach($children as $child) {
               if ($status === 'trash') {
                  wp_trash_post($child);
               } elseif ($status === 'delete') {
                  wp_delete_post($child, true);
               }else {
                  jvb_update_post([
                     'ID'  => $child,
                     'post_status'  => $status
                  ]);
               }
            }
         }
      }
   }
   protected function handlePost(string|int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
   {
      // New post creation
      if (str_starts_with((string)$ID, 'new')) {
         $newId = wp_insert_post([
            'post_author' => $this->userId,
            'post_type'   => $registrar->getBased(),
            'post_title'  => $data['post_title'] ?? apply_filters('jvbDefaultTitle', '', $registrar->getSlug()),
            'post_status' => $data['status'] ?? 'draft',
         ]);
         error_log('Created new post: '.print_r($newId, true));
         if (!$newId || is_wp_error($newId)) {
            $results['errors'][$ID] = 'Could not create post';
            $progress->failItem($ID, 'Could not create post');
            return $results;
         }
         $results['newPosts'][$ID] = $newId;
         $this->savePostFields($newId, $data);
         unset($data['content']);
         $results['success'][$newId] = $data;
         $progress->advance();
         return $results;
      }
      //Existing post update
      if (!$this->verifyOwnership((int)$ID)) {
         $progress->failItem($ID, 'No permission to modify this post');
         $results['errors'][$ID] = 'No permission';
         return $results;
      }
      $result = $this->savePostFields((int)$ID, $data);
      unset($data['content']);
      if ($result) {
         $results['success'][$ID] = $data;
      } else {
         $results['errors'][$ID] = 'Could not update post data';
      }
      if ($registrar && $registrar->hasFeature('is_timeline')) {
         $post = get_post((int)$ID);
         $parentId = $post->post_parent > 0 ? $post->post_parent : $post->ID;
         $fields = $registrar->getFields();
         $sharedFields = array_keys(array_filter($fields, function ($field) {
            return !array_key_exists('for_all', $field) || !$field['for_all'];
         }));
         if (array_key_exists('timeline_gallery', $data)) {
            //This should only happen if we delete an image from the gallery
            $changes = explode(',', $data['timeline_gallery']);
            $timelinePosts = $this->getTimelinePosts($parentId);
            if (!empty($timelinePosts)) {
               $posts = array_map(function($item) { return $item->ID; }, $timelinePosts);
               $changed = false;
               foreach ($posts as $tID) {
                  if (!in_array($tID, $changes)) {
                     $changed = true;
                     wp_delete_post($tID, true);
                  }
               }
               if ($changed) {
                  $results['timelineParents'][] = $parentId;
               }
            }
         }
         if (array_key_exists('post_date', $data) && !in_array($parentId, $results['timelineParents'])) {
            $results['timelineParents'][] = $parentId;
         }
         if ($parentId === $ID) {
            if (array_key_exists('post_status', $data) && !array_key_exists($parentId, $results['timelineStatus'])) {
               $results['timelineStatus'][$parentId] = $data['post_status'];
            }
            if (count(array_intersect($sharedFields, array_keys($data))) > 0) {
               if (!array_key_exists($parentId, $results['timelineSharedFields'])) {
                  $results['timelineSharedFields'][$parentId] = [];
               }
               $temp = array_intersect($sharedFields, array_keys($data));
               $results['timelineSharedFields'][$parentId] = array_unique(array_merge($results['timelineSharedFields'][$parentId], $temp));
            }
         }
      }
      $progress->advance();
      return $results;
   }
   protected function handleTerm(int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
   {
      error_log('Handling term '.$ID.' with data: '.print_r($data, true));
      //Existing term update
      if ($registrar->hasFeature('is_ownable') && (!JVB()->roles()->isOwner($this->userId, $ID) && !JVB()->roles()->isManager($this->userId, $ID))) {
         error_log('Term is ownable. User does not own this term.');
         $progress->failItem($ID, 'No permission to modify this term');
         $results['errors'][$ID] = 'No permission';
         return $results;
      }
      $result = $this->saveTermFields($ID, $data);
      unset($data['content']);
      if ($result) {
         $results['success'][$ID] = $data;
      } else {
         $results['errors'][$ID] = 'Could not update term data';
      }
      $progress->advance();
      return $results;
   }
   protected function handleUser(int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
   {
      //Existing term update
      if ($ID !== $this->userId || !user_can($this->userId, 'manage_options')) {
         $progress->failItem($ID, 'No permission to modify this term');
         $results['errors'][$ID] = 'No permission';
         return $results;
      }
      $result = $this->saveUserFields($ID, $data);
      unset($data['content']);
      if ($result) {
         $results['success'][$ID] = $data;
      } else {
         $results['errors'][$ID] = 'Could not update post data';
      }
      $progress->advance();
      return $results;
   }
}