Jake Vanderwerf
2026-01-22 58e8ae0759ccfa97c478ccae4e0778bdce70966f
inc/managers/queue/executors/ContentExecutor.php
@@ -83,22 +83,12 @@
      $results = [];
      $errors = [];
      $updateTimelineOrder = [];
      foreach ($posts as $id => $postData) {
         try {
            $content = $postData['content'] ?? '';
            // Timeline posts
            if (Features::forContent($content)->has('is_timeline') && isset($postData['timeline'])) {
               $parentId = (int)$id;
               if ($parentId === 0) {
                  $progress->failItem($id, 'Invalid parent post ID for timeline');
                  continue;
               }
               $results[$id] = $this->processTimelinePost($parentId, $postData);
               $progress->advance(1);
               continue;
            }
            // New post creation
            if (str_starts_with((string)$id, 'new')) {
               $newId = wp_insert_post([
@@ -115,6 +105,11 @@
               $this->savePostFields($newId, $postData);
               $results[$id] = ['success' => true, 'new_id' => $newId];
               if (Features::forContent($content)->has('is_timeline')) {
                  $this->updateTimelineLatestDate($newId);
               }
               $progress->advance(1);
               continue;
            }
@@ -124,8 +119,43 @@
               $progress->failItem($id, 'No permission to modify this post');
               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];
            $progress->advance(1);
@@ -140,6 +170,11 @@
            $errors[$id] = $e->getMessage();
         }
      }
      if (!empty($updateTimelineOrder)) {
         foreach ($updateTimelineOrder as $parentID) {
            $this->reorderTimelineByDate($parentID);
         }
      }
      // Send notification
      if (jvbSiteHasNotifications()) {
@@ -177,6 +212,7 @@
               break;
            case 'draft':
               wp_update_post(['ID' => $postId, 'post_status' => 'draft']);
               unset($postData['post_status']);
               break;
            case 'trash':
               wp_trash_post($postId);
@@ -187,9 +223,201 @@
         }
      }
      // 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): void
   {
      $parent = get_post($parentId);
      if (!$parent) return;
      // 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];
      // 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;
      }
      $this->updateTimelineLatestDate($newParent->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'
      ]);
      $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);
      // Store as UNIX timestamp
      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'] ?? '';
@@ -325,9 +553,11 @@
      $results = [];
      foreach ($files as $img) {
         $title = $this->generatePostTitle($data['content']);
         $postId = wp_insert_post([
            'post_type'   => $this->postType,
            'post_title'  => $this->generatePostTitle($data['content']),
            'post_title'  => $title,
            'post_slug'    => sanitize_title($title),
            'post_status' => 'draft',
            'post_author' => $operation->userId,
         ]);
@@ -342,202 +572,6 @@
   }
   // ─────────────────────────────────────────────────────────────
   // Timeline Processing
   // ─────────────────────────────────────────────────────────────
   private function processTimelinePost(int $parentId, array $postData): array
   {
      if (!$this->verifyOwnership($parentId)) {
         return ['success' => false, 'message' => 'No permission'];
      }
      $content = $postData['content'];
      $this->initTimelineFields($content);
      $parentPost = get_post($parentId);
      $parentIsPublished = ($parentPost->post_status === 'publish');
      // Extract shared data (excluding post_thumbnail)
      $sharedData = array_filter($postData, function ($key) {
         return in_array($key, $this->timelineSharedFields)
            && !in_array($key, ['content', 'user'])
            && $key !== 'post_thumbnail';
      }, ARRAY_FILTER_USE_KEY);
      if (!isset($sharedData['post_title']) && isset($postData['timeline'][0]['post_title'])) {
         $sharedData['post_title'] = $postData['timeline'][0]['post_title'];
      }
      if (!isset($postData['timeline']) || !is_array($postData['timeline'])) {
         return ['success' => false, 'message' => 'No timeline data'];
      }
      // Validate parent is in timeline
      $index = array_search((string)$parentId, array_column($postData['timeline'], 'id'));
      if ($index === false) {
         return ['success' => false, 'message' => 'Missing parent id'];
      }
      // Handle parent reordering if needed
      if ($index !== 0) {
         $parentId = $this->reorderTimelineParent($parentId, $postData['timeline'], $index);
         $parentPost = get_post($parentId);
         $parentIsPublished = ($parentPost->post_status === 'publish');
      }
      // Shared taxonomies (excluding title and thumbnail)
      $sharedTaxonomies = array_filter($sharedData, function ($key) {
         return !in_array($key, ['post_title', 'post_thumbnail']);
      }, ARRAY_FILTER_USE_KEY);
      $existingChildren = get_children([
         'post_parent'  => $parentId,
         'orderby'      => 'menu_order',
         'post_status'  => ['publish', 'draft'],
         'fields'       => 'ids',
      ]);
      $errors = [];
      $success = [];
      foreach ($postData['timeline'] as $order => $timeline) {
         $result = $this->processTimelineEntry(
            $timeline,
            $order,
            $parentId,
            $parentIsPublished,
            $sharedTaxonomies,
            $existingChildren,
            $content
         );
         if ($result['success']) {
            $success[] = $result;
            if (isset($result['child_id']) && in_array($result['child_id'], $existingChildren)) {
               unset($existingChildren[array_search($result['child_id'], $existingChildren)]);
            }
         } else {
            $errors[] = $result;
         }
      }
      // Trash orphaned children
      foreach ($existingChildren as $orphanId) {
         wp_trash_post($orphanId);
      }
      return [
         'success' => empty($errors),
         'updated' => count($success),
         'errors'  => $errors,
      ];
   }
   private function processTimelineEntry(
      array $timeline,
      int $order,
      int $parentId,
      bool $parentIsPublished,
      array $sharedTaxonomies,
      array &$existingChildren,
      string $content
   ): array {
      $isParent = ((int)($timeline['id'] ?? 0) === $parentId);
      // Get unique fields for this entry
      $allowedFields = array_filter($timeline, function ($key) {
         return in_array($key, $this->timelineUniqueFields) && !in_array($key, ['content', 'user']);
      }, ARRAY_FILTER_USE_KEY);
      // Determine title
      $providedTitle = $timeline['post_title'] ?? '';
      $autoPattern = '/^.+Treatment #?\d+$/';
      if ($isParent) {
         $allowedFields['post_title'] = $providedTitle ?: ($sharedTaxonomies['post_title'] ?? get_post($parentId)->post_title);
      } else {
         if (empty($providedTitle) || preg_match($autoPattern, $providedTitle)) {
            $allowedFields['post_title'] = 'Treatment ' . $order;
         } else {
            $allowedFields['post_title'] = $providedTitle;
         }
      }
      $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
      // Create child if needed
      $childId = $timeline['id'] ?? null;
      if (!$childId || !is_numeric($childId)) {
         $childId = wp_insert_post([
            'post_author' => $this->userId,
            'post_type'   => jvbCheckBase($content),
            'post_title'  => $allowedFields['post_title'],
            'post_parent' => $parentId,
            'menu_order'  => $order,
            'post_status' => $parentIsPublished ? 'publish' : 'draft',
         ]);
         if (!$childId || is_wp_error($childId)) {
            return ['success' => false, 'message' => 'Could not create child post'];
         }
      }
      // Update post
      $postUpdates = ['ID' => $childId];
      if (!$isParent) {
         $postUpdates['menu_order'] = $order;
         if ($parentIsPublished) {
            $currentPost = get_post($childId);
            if ($currentPost && $currentPost->post_status !== 'publish') {
               $postUpdates['post_status'] = 'publish';
            }
         }
      }
      if (isset($allowedFields['post_title'])) {
         $postUpdates['post_title'] = $allowedFields['post_title'];
         unset($allowedFields['post_title']);
      }
      wp_update_post($postUpdates);
      // Save meta fields
      if (!empty($allowedFields)) {
         $meta = new MetaManager($childId, 'post');
         $meta->setAll($allowedFields);
      }
      return ['success' => true, 'child_id' => $childId];
   }
   private function reorderTimelineParent(int $currentParentId, array $timeline, int $currentIndex): int
   {
      $newParentId = $timeline[0]['id'] ?? null;
      if (!is_numeric($newParentId) || (int)$newParentId <= 0) {
         return $currentParentId;
      }
      $newParentId = (int)$newParentId;
      // Make new parent a top-level post
      wp_update_post(['ID' => $newParentId, 'post_parent' => 0]);
      // Make old parent a child
      wp_update_post(['ID' => $currentParentId, 'post_parent' => $newParentId]);
      // Move existing children to new parent
      $existingChildren = get_children(['post_parent' => $currentParentId, 'fields' => 'ids']);
      foreach ($existingChildren as $childId) {
         if ($childId !== $newParentId) {
            wp_update_post(['ID' => $childId, 'post_parent' => $newParentId]);
         }
      }
      return $newParentId;
   }
   // ─────────────────────────────────────────────────────────────
   // Helpers
   // ─────────────────────────────────────────────────────────────