Jake Vanderwerf
2026-05-11 ac444cba221832c012c0435fdc8339fe9f37febb
inc/rest/routes/ContentRoutes.php
@@ -2,46 +2,47 @@
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\managers\queue\executors\ContentExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\rest\RestRouteManager;
use JVBase\managers\Cache;
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Response;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use WP_Post;
use WP_Query;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
use WP_Term;
use WP_Term_Query;
if (!defined('ABSPATH')) {
   exit; // Exit if accessed directly
}
class ContentRoutes extends RestRouteManager
class ContentRoutes extends Rest
{
   protected array $fields = [];
   protected array $taxonomies = [];
   protected MetaManager $meta;
   protected string $post_type = '';
   protected string $user_id = '';
   //For Timeline-specific posts
   protected array $timelineSharedFields = [];
   protected array $timelineUniqueFields = [];
   protected static ?string $action = 'dash-';
   protected Meta $meta;
   public function __construct()
   {
      $this->cache_name = 'user_content_' . get_current_user_id();
      $this->cacheName = 'user_content_' . get_current_user_id();
      parent::__construct();
      if (JVB_TESTING) {
         $this->cache->flush();
      }
      $this->action = 'dash-';
      $this->operation_type = 'content_update';
      $this->cache->connect('post', true);
      $this->cache->connect('term', true);
      add_action('init', [$this, 'registerContentExecutors'], 5);
   }
@@ -60,10 +61,10 @@
         chunkSize: 10
      ));
      // Batch creation (from uploads)
      $registry->register('batch_creation', new TypeConfig(
         executor: $executor
      ));
      // Batch creation (from uploads) TODO: I believe this is all handled by UploadExecutor
//    $registry->register('batch_creation', new TypeConfig(
//       executor: $executor
//    ));
   }
   /**
@@ -72,45 +73,42 @@
    */
   public function registerRoutes(): void
   {
      // Base content endpoint
      register_rest_route($this->namespace, "/content", [
         [
            'methods' => 'GET',
            'callback' => [$this, 'handleContentRequest'],
            'permission_callback' => [$this, 'checkPermission'],
         ],
         [
            'methods' => 'POST',
            'callback' => [$this, 'handleContentUpdate'],
            'permission_callback' => [$this, 'checkPermission']
         ]
      ]);
      //TODO: consolidate create/batch in with create? I don't think we are ever creating a single item
      register_rest_route($this->namespace, "/create", [
         [
            'methods' => 'POST',
            'callback' => [$this, 'handleContentCreate'],
            'permission_callback' => [$this, 'checkPermission']
         ]
      ]);
      register_rest_route($this->namespace, "/create/batch", [
         [
            'methods' => 'POST',
            'callback' => [$this, 'handleBatchCreation'],
            'permission_callback' => [$this, 'checkPermission']
         ]
      ]);
      Route::for('content')
         ->get([$this, 'getContent'])
         ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']]))
         ->args([
            'content' => 'string|required',
            'status' => 'string|default:all',
            'page' => 'integer|default:1|min:1',
            'per_page' => 'integer|default:30|min:1|max:100',
            'orderby' => 'string|enum:date,alphabetical|default:date',
            'order' => 'string|enum:asc,desc|default:desc',
            'search' => 'string',
            'date-filter' => 'string',
            'dateFrom' => 'string',
            'dateTo' => 'string',
         ])
         ->rateLimit(20)
         ->post([$this, 'postContent'])
         ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']]))
         ->rateLimit(30)
         ->args([
            'user'   => 'int|required',
            'posts' => 'required',
            'content' => 'string',
         ])
         ->register();
   }
   protected function initTimelineFields(string $content): void
   {
      $content = jvbNoBase($content);
      if (!Features::forContent($content)->has('is_timeline')) {
      $config = Registrar::getInstance($content);
      if (!$config || !$config->hasFeature('is_timeline')) {
         return;
      }
      $config = Features::getConfig($content);
      $this->fields = $config['fields'];
      $this->fields = $config->getFields();
      $this->timelineSharedFields = $this->getTimelineSharedFields($content);
      array_unshift($this->timelineSharedFields, 'post_thumbnail');
@@ -123,11 +121,12 @@
   public function getTimelineUniqueFields(string $content): array
   {
      $content = jvbNoBase($content);
      if (!Features::forContent($content)->has('is_timeline')) {
      $registrar = Registrar::getInstance($content);
      if (!$registrar || !$registrar->hasFeature('is_timeline')) {
         return [];
      }
      $config = Features::getConfig($content);
      $allFields = $config['fields'];
      $allFields = $registrar->getFields();
      return array_keys(array_filter($allFields, function ($field) {
         if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
@@ -140,17 +139,15 @@
   public function getTimelineSharedFields(string $content): array
   {
      $content = jvbNoBase($content);
      if (!Features::forContent($content)->has('is_timeline')) {
      $registrar = Registrar::getInstance($content);
      if (!$registrar || !$registrar->hasFeature('is_timeline')) {
         return [];
      }
      $config = Features::getConfig($content);
      if (!$config || empty($config)) {
         return [];
      }
      $allFields = $config['fields'] ?? [];
      $allFields = $registrar->getFields()??[];
      return array_keys(array_filter($allFields, function ($field) {
         if (!array_key_exists('for_all', $field) || $field['for_all'] === false) {
         if (!array_key_exists('for_all', $field) || is_null($field['for_all']) || $field['for_all'] === false) {
            return true;
         }
         return false;
@@ -163,86 +160,33 @@
    *
    * @return WP_REST_Response
    */
   public function handleContentUpdate(WP_REST_Request $request): WP_REST_Response
   public function postContent(WP_REST_Request $request): WP_REST_Response
   {
      $data = $request->get_params();
      $user_id = $data['user'];
      if (!$this->userCheck($user_id)) {
         return new WP_REST_Response([
            'success' => true,
            'message' => 'You for real?'
         ]);
      if (!array_key_exists('posts', $data) || !is_array($data['posts'])) {
         return Response::success(['message'=>'No posts found in request']);
      }
      if (!array_key_exists('posts', $data) || !is_array($data['posts'])) {
         return new WP_REST_Response([
            'success' => true,
            'message' => 'No posts found'
         ]);
      }
      $count = count($data['posts']);
      $operationId = $data['id'];
      unset($data['user']);
      unset($data['id']);
      error_log('[CONTENT]:'.print_r($data, true));
      $queue = JVB()->queue();
      $queue->queueOperation(
         'content_update',
         $user_id,
         $data,
         [
            'count' => $count,
            'chunk_key' => 'posts',
            'chunk_size' => 10,
            'operation_id' => $operationId
         ]
      );
      return new WP_REST_Response([
         'success' => true,
         'message' => 'Queued for processing',
         'operation' => $operationId
      ]);
   }
   /**
    * Handle content creation
    * @param WP_REST_Request $request
    *
    * @return WP_REST_Response
    */
   public function handleContentCreate(WP_REST_Request $request): WP_REST_Response
   {
      $data = $request->get_json_params();
      $user_id = $data['user'];
      if (!isset($data['posts']) || !is_array($data['posts'])) {
         return new WP_REST_Response([
            'success' => false,
            'message' => 'Invalid request format'
         ]);
      }
      $count = count($data['posts']);
      $operationId = $data['id'];
      unset($data['user']);
      unset($data['id']);
      JVB()->queue()->queueOperation(
         'batch_creation',
         $user_id,
         $data,
         [
            'count' => $count,
            'operation_id' => $operationId,
         ]
      );
      return new WP_REST_Response([
         'success' => true,
         'message' => 'Queued for processing',
         'operation' => $operationId
      ]);
      return Response::queued($operationId);
   }
@@ -252,18 +196,29 @@
    *
    * @return WP_REST_Response
    */
   public function handleContentRequest(WP_REST_Request $request): WP_REST_Response
   public function getContent(WP_REST_Request $request): WP_REST_Response
   {
      $params = $request->get_params();
      $user_id = $params['user'];
      error_log('getContent::params '.print_r($params, true));
      if (!$this->userCheck($user_id)) {
         return new WP_REST_Response([
            'success' => false,
            'message' => 'User does not match up. Are you a bot?',
         ]);
      $registrar = Registrar::getInstance($params['content']);
      switch ($registrar->getType()) {
         case 'term':
            return $this->getTerms($request, $params, $registrar);
         case 'user':
            //TODO maybe do something?
            break;
         case 'post':
            return $this->getPosts($request, $params, $registrar);
      }
      return $this->error('Something went wrong, this does not appear to have a proper content type');
   }
   public function getPosts(WP_REST_Request $request, array $params, Registrar $registrar):WP_REST_Response
   {
      $user_id = $params['user'];
      $post_status = $params['status'];
      if ($post_status === 'all') {
         $post_status = ['publish', 'draft'];
@@ -272,7 +227,6 @@
      }
      $post_type = str_replace('-', '_', jvbCheckBase($params['content']));
      // Build query args
      $args = [
         'post_type' => $post_type,
@@ -283,13 +237,15 @@
         'author' => $user_id,
         'post_status' => $post_status
      ];
      //Only top level posts for timeline types
      if (Features::forContent($post_type)->has('is_timeline')) {
      if ($registrar?->hasFeature('is_timeline')) {
         $args['post_parent'] = 0;
      }
      //Calendar filters
      if (Features::forContent($post_type)->has('is_calendar')) {
      if ($registrar?->hasFeature('is_calendar')) {
         $args = $this->applyCalendarFilters($args, $params);
      }
      $taxonomies = array_filter($params, function ($param) {
@@ -316,6 +272,70 @@
         $args['s'] = sanitize_text_field($params['search']);
      }
      $key = $this->cache->generateKey($args);
      $cached = $this->checkCache($key, $request);
      if ($cached) {
         return $cached;
      }
      $this->post_type = jvbCheckBase($params['content'] ?? $params['type']);
      if (array_key_exists('s', $args)) {
         $args = $this->applySearchFilters($args, $params);
      }
      // Run query
      $query = new WP_Query($args);
      $registrar = Registrar::getInstance($this->post_type);
      $this->fields = $registrar->getFields()??[];
      $this->taxonomies = $this->getTaxonomies($this->post_type);
      $posts = array_map([$this, 'preparePost'], $query->posts);
      $data = [
         'items' => $posts,
         'total' => $query->found_posts,
         'total_pages' => $query->max_num_pages,
         'has_more'  => $args['paged']??1 < $query->max_num_pages,
      ];
      $this->cache->set($key, $data);
      $response = Response::success($data);
      return $this->addCacheHeaders($response);
   }
   public function getTerms(WP_REST_Request $request, array $params, Registrar $registrar):WP_REST_Response
   {
      // Build query args
      $args = [
         'taxonomy'     => jvbCheckBase($params['content']),
         'number'    => $params['per_page'] ?? 30,
         'orderby'      => 'name',
         'order'     => 'DESC',
         'hide_empty'   => false,
      ];
      $paged = $params['page']??1;
      $args['page'] = $paged;
      if ($paged > 1) {
         $args['offset'] = ($paged-1) * $args['number'];
      }
      //TODO
//    if (array_key_exists('taxonomies', $params)) {
//       $args = $this->applyTaxonomyFilters($args, $params);
//    }
//    if (array_key_exists('date-filter', $params) || array_key_exists('dateFrom', $params)) {
//       $args = $this->applyDateFilters($args, $params);
//    }
      if (array_key_exists('orderby', $params) || array_key_exists('order', $params)) {
         $args = $this->applyOrderFilters($args, $params);
      }
      if (array_key_exists('search', $params)) {
         $args['s'] = sanitize_text_field($params['search']);
      }
      $key = $this->cache->generateKey($args);
      // Check HTTP cache headers with the specific content type
@@ -324,13 +344,11 @@
         return $cache_check;
      }
      $cache = $this->cache->get($key);
      if ($cache) {
         $response = new WP_REST_Response($cache);
         $response = Response::success($cache);
         return $this->addCacheHeaders($response);
      }
      $this->post_type = jvbCheckBase($params['content'] ?? $params['type']);
      // Only expand search to taxonomies if we're actually going to query
      if (array_key_exists('s', $args)) {
@@ -338,23 +356,37 @@
      }
      // Run query
      $query = new WP_Query($args);
      $query = new WP_Term_Query($args);
      $this->fields = jvbGetFields(str_replace('-', '_', $this->post_type));
      $this->taxonomies = $this->getTaxonomies($this->post_type);
      $posts = array_map([$this, 'prepareItem'], $query->posts);
      $terms = $query->get_terms();
      $data = [
         'items' => $posts,
         'total' => $query->found_posts,
         'total_pages' => $query->max_num_pages
         'total'        => 0,
         'total_pages'  => 0,
         'has_more'     => false
      ];
      if (!is_wp_error($terms) && !empty($terms))
      {
         $total = get_terms([
            'taxonomy'     => $args['taxonomy'],
            'hide_empty'   => false,
            'fields'    => 'count'
         ]);
         $data['total'] = $total;
         $data['total_pages'] = max($total/$args['number'], 1);
         $data['has_more'] = ($args['page'] * $args['number']) < $total;
      } else {
         $terms = [];
      }
      $this->fields = $registrar->getFields()??[];
      $this->taxonomies = [];
      $data['items'] =array_map([$this, 'prepareTerm'], $terms);
      $this->cache->set($key, $data);
      $response = new WP_REST_Response($data);
      $response = Response::success($data);
      return $this->addCacheHeaders($response);
   }
@@ -447,465 +479,23 @@
    */
   protected function getTaxonomies(string $content): array
   {
      $taxonomy_for = jvbGlobalTaxonomyFor();
      $out = [];
      foreach ($taxonomy_for as $tax => $postTypes) {
         if (in_array($content, $postTypes)) {
            $out[BASE . $tax] = [
               'label' => JVB_CONTENT[$content]['plural'],
               'icon' => $tax,
            ];
         }
      $registrar = Registrar::getInstance($content);
      if (!$registrar || $registrar->getType()!== 'post') {
         return [];
      }
      return $out;
   }
   /**
    * Processes operation from queue
    * @param object $operation
    * @param array $data
    *
    * @return array
    */
   protected function processBatches(object $operation, array $data): array
   {
      $this->user_id = $operation->user_id;
      $posts = $data['posts'];
      if (empty($posts)) {
         return [
            'success' => false,
            'message' => 'No posts to update'
      $out = [];
      foreach ($registrar->registrar->taxonomies as $tax) {
         $taxReg = Registrar::getInstance($tax);
         $out[jvbCheckBase($tax)] = [
            'label'  => $taxReg->getPlural(),
            'icon'   => $taxReg->getIcon()??jvbDefaultIcon()
         ];
      }
      $results = [];
      foreach ($posts as $ID => $post_data) {
         if (Features::forContent($post_data['content'])->has('is_timeline') && array_key_exists('timeline', $post_data)) {
            // Handle timeline posts - ensure we have a valid integer ID
            $parent_id = (int)$ID;
            // Skip if ID is invalid (0, 'null', etc would become 0)
            if ($parent_id === 0) {
               error_log('Invalid timeline parent ID: ' . $ID);
               $results[$ID] = [
                  'success' => false,
                  'message' => 'Invalid parent post ID for timeline'
               ];
               continue;
            }
            $results[$ID] = $this->processTimelinePost($parent_id, $post_data);
            continue;
         }
         if (str_starts_with($ID, 'new')) {
            error_log('New post detected. Creating... with: ' . print_r([
                  'post_author' => $this->user_id,
                  'post_type' => jvbCheckBase($post_data['content']),
                  'post_title' => $post_data['post_title'] ?? '',
                  'post_status' => $post_data['status'] ?? 'draft',
               ], true));
            error_log('Recieved Data: ' . print_r($post_data, true));
            $ID = wp_insert_post([
               'post_author' => $this->user_id,
               'post_type' => jvbCheckBase($post_data['content']),
               'post_title' => $post_data['post_title'] ?? '',
               'post_status' => $post_data['status'] ?? 'draft',
            ]);
            if (!$ID || is_wp_error($ID)) {
               $results[$ID] = [
                  'success' => false,
                  'message' => 'Couldn\'t Create Post'
               ];
               continue;
            }
            $fields = jvbGetFields($post_data['content']);
            $allowedFields = array_filter($post_data, function ($key) use ($fields) {
               return array_key_exists($key, $fields);
            }, ARRAY_FILTER_USE_KEY);
            $meta = new MetaManager($ID, 'post');
            $success = $meta->setAll($allowedFields);
            $results[$ID] = [
               'success' => $success
            ];
         } else {
            if (!$this->verifyOwnership($ID)) {
               $results[$ID] = [
                  'success' => false,
                  'message' => 'No permission to modify this post'
               ];
               continue;
            }
            error_log('Saving post data: ' . print_r($post_data, true));
            if (array_key_exists('post_status', $post_data)) {
               switch ($post_data['post_status']) {
                  case 'publish':
                     unset($post_data['post_status']);
                     if (user_can($this->user_id, 'manage_options') || user_can($this->user_id, 'skip_moderation')) {
                        $result = wp_update_post(['ID' => $ID, 'post_status' => 'publish']);
                     }
                     break;
                  case 'draft':
                     $result = wp_update_post([
                        'ID' => $ID,
                        'post_status' => 'draft'
                     ]);
                     break;
                  case 'trash':
                     $result = wp_trash_post($ID);
                     break;
                  case 'delete':
                     $result = wp_delete_post($ID, true);
                     return ['success' => (bool)$result];
               }
            }
            error_log('Updating data: ' . print_r($post_data, true));
            $fields = jvbGetFields($post_data['content']);
            $allowedFields = array_filter($post_data, function ($key) use ($fields) {
               return array_key_exists($key, $fields);
            }, ARRAY_FILTER_USE_KEY);
            error_log('Allowed Fields: ' . print_r($allowedFields, true));
            $meta = new MetaManager($ID, 'post');
            $success = $meta->setAll($allowedFields);
            $results[$ID] = [
               'success' => $success
            ];
         }
      }
      if (jvbSiteHasNotifications()) {
         $this->notifications = JVB()->notification();
         $this->notifications->addNotification(
            $this->user_id,
            'content_update_complete',
            null,
            'Content updates completed!'
         );
      }
      return [
         'success' => true,
         'result' => $results
      ];
      return $out;
   }
   /**
    * Extracts the postdata for timeline post child posts from the pseudo-repeater element
    * @param int $parent_id
    * @param array $post_data
    * @return array|true[]
    */
   protected function processTimelinePost(int $parent_id, array $post_data): array
   {
      if (!$this->verifyOwnership($parent_id)) {
         return ['success' => false, 'message' => 'No permission'];
      }
      error_log('[Processing Timeline Post...');
      $ignore = ['content', 'user'];
      $this->fields = jvbGetFields($post_data['content']);
      $this->initTimelineFields($post_data['content']);
      // Get parent post details
      $parent_post = get_post($parent_id);
      $parent_title = $parent_post->post_title;
      $parent_is_published = ($parent_post->post_status === 'publish');
      // Extract shared data from top level (excluding post_thumbnail which is unique per post)
      $sharedData = array_filter($post_data, function ($key) use ($ignore) {
         return in_array($key, $this->timelineSharedFields)
            && !in_array($key, $ignore)
            && $key !== 'post_thumbnail';
      }, ARRAY_FILTER_USE_KEY);
      // If no shared post_title at top level, extract from first timeline entry
      if (!isset($sharedData['post_title']) && isset($post_data['timeline'][0]['post_title'])) {
         $sharedData['post_title'] = $post_data['timeline'][0]['post_title'];
      }
      $clearParent = false;
      if (array_key_exists('timeline', $post_data) && is_array($post_data['timeline'])) {
         // Remove post_title and post_thumbnail from shared taxonomies
         $sharedTaxonomies = array_filter($sharedData, function ($key) {
            return $key !== 'post_title' && $key !== 'post_thumbnail';
         }, ARRAY_FILTER_USE_KEY);
         // Ensure the parent post exists and is still first in the array
         $index = array_search((string)$parent_id, array_column($post_data['timeline'], 'id'));
         if ($index === false) {
            return [
               'success' => false,
               'message' => 'Missing parent id. This should not have happened'
            ];
         }
         if ($index !== 0) {
            $new_parent_id = $post_data['timeline'][0]['id'];
            if (is_numeric($new_parent_id) && (int)$new_parent_id > 0) {
               $new_parent_id = (int)$new_parent_id;
               wp_update_post([
                  'ID' => $new_parent_id,
                  'post_parent' => 0
               ]);
               wp_update_post([
                  'ID' => $parent_id,
                  'post_parent' => $new_parent_id
               ]);
               $existing_children = get_children([
                  'post_parent' => $parent_id,
                  'fields' => 'ids'
               ]);
               foreach ($existing_children as $child_id) {
                  if ($child_id !== $new_parent_id) {
                     wp_update_post([
                        'ID' => $child_id,
                        'post_parent' => $new_parent_id
                     ]);
                  }
               }
               // Update parent references
               $parent_id = $new_parent_id;
               $parent_post = get_post($parent_id);
               $parent_title = $parent_post->post_title;
               $parent_is_published = ($parent_post->post_status === 'publish');
            } else {
               $item = $post_data['timeline'][$index];
               unset($post_data['timeline'][$index]);
               array_unshift($post_data['timeline'], $item);
            }
         }
         $errors = [];
         $success = [];
         $existing_children = get_children([
            'post_parent' => $parent_id,
            'orderby' => 'menu_order',
            'post_status' => ['publish', 'draft'],
            'fields' => 'ids'
         ]);
         $prevDate = null;
         $latest_date = null;
         $earliest_date = null;
         foreach ($post_data['timeline'] as $order => $timeline) {
            // Get unique fields for this specific timeline entry
            $allowedFields = array_filter($timeline, function ($key) use ($ignore) {
               return in_array($key, $this->timelineUniqueFields) && !in_array($key, $ignore);
            }, ARRAY_FILTER_USE_KEY);
            // Determine the post title
            $is_parent = ((int)$timeline['id'] === $parent_id);
            $provided_title = $timeline['post_title'] ?? '';
            $auto_generated_pattern = '/^.+Treatment #?\d+$/'; // Matches "Title - Treatment #1" or "Title - Treatment 1"
            if ($is_parent) {
               // Parent keeps its own title or uses shared title
               $allowedFields['post_title'] = $provided_title ?: ($sharedData['post_title'] ?? $parent_title);
            } else {
               // For child posts, auto-generate if:
               // 1. No title provided, OR
               // 2. Title matches auto-generated pattern (meaning it wasn't customized)
               if (empty($provided_title) || preg_match($auto_generated_pattern, $provided_title)) {
                  $allowedFields['post_title'] = 'Treatment ' . $order;
               } else {
                  // Keep custom title
                  $allowedFields['post_title'] = $provided_title;
               }
            }
            // Merge with shared taxonomies AFTER setting unique fields
            $allowedFields = array_merge($sharedTaxonomies, $allowedFields);
            // Handle post creation if needed
            if (!array_key_exists('id', $timeline) || !is_numeric($timeline['id'])) {
               $newChild = wp_insert_post([
                  'post_author' => $this->user_id,
                  'post_type' => jvbCheckBase($post_data['content']),
                  'post_title' => $allowedFields['post_title'],
                  'post_parent' => $parent_id,
                  'menu_order' => $order,
                  'post_status' => $parent_is_published ? 'publish' : 'draft'
               ]);
               if (!$newChild || is_wp_error($newChild)) {
                  $errors[] = [
                     'message' => 'Could not create child post',
                     'data' => $timeline
                  ];
                  continue;
               }
               $timeline['id'] = $newChild;
            }
            if (in_array((int)$timeline['id'], $existing_children)) {
               unset($existing_children[array_search((int)$timeline['id'], $existing_children)]);
            }
            // Update post status and menu order
            $post_updates = ['ID' => $timeline['id']];
            if (!$is_parent) {
               $post_updates['menu_order'] = $order;
               // Auto-publish child if parent is published
               if ($parent_is_published) {
                  $current_post = get_post($timeline['id']);
                  if ($current_post && $current_post->post_status !== 'publish') {
                     $post_updates['post_status'] = 'publish';
                  }
               }
            }
            if (count($post_updates) > 1) {
               $result = wp_update_post($post_updates);
               error_log('Updated post ' . $timeline['id'] . ' with: ' . print_r($post_updates, true) . ' Result: ' . $result);
               $clearParent = true;
            }
            // Update metadata
            $meta = new MetaManager($timeline['id'], 'post');
            $oldValues = $meta->getAll(array_keys($allowedFields));
//          // Set number taxonomy to menu_order (always update for reordering)
//          if (!$is_parent) {
//             $number_value = $order;
//             $term = get_term_by('name', (string)$number_value, BASE . 'number');
//             if (!$term) {
//                $result = wp_insert_term((string)$number_value, BASE . 'number');
//                if ($result && !is_wp_error($result)) {
//                   $term = $result['term_id'];
//                }
//             } else {
//                $term = $term->term_id;
//             }
//             $allowedFields['number'] = $term;
//          }
            // Auto-timeline logic
            if ($prevDate) {
               $newDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : null);
               if ($newDate) {
                  $date1 = new \DateTime($prevDate);
                  $date2 = new \DateTime($newDate);
                  $weeks = floor($date1->diff($date2)->days / 7);
                  if ($weeks > 0) {
                     $termToCheck = $weeks . ' Weeks';
                     $term = get_term_by('name', $termToCheck, BASE . 'timeline');
                     if (!$term) {
                        $result = wp_insert_term($termToCheck, BASE . 'timeline');
                        if ($result && !is_wp_error($result)) {
                           $term = $result['term_id'];
                        }
                     } else {
                        $term = $term->term_id;
                     }
                     $allowedFields['timeline'] = $term;
                  }
               }
            }
            $prevDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : $prevDate);
            $updateValues = array_filter($allowedFields, function ($value, $key) use ($oldValues) {
               return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]);
            }, ARRAY_FILTER_USE_BOTH);
            $meta->setAll($updateValues);
            $timeline['id'] = (int)$timeline['id'];
            $success[] = $timeline['id'];
         }
      }
      // Delete any remaining children that no longer exist
      if (!empty($existing_children)) {
         foreach ($existing_children as $ID) {
            wp_delete_post($ID);
         }
      }
      if ($clearParent) {
         $this->cache->flush();
         Cache::onPostChange($parent_id, $parent_post);
      }
      return ['success' => true, 'data' => [
         'success' => $success,
         'errors' => $errors
      ]];
   }
   /**
    * Handle batch content creation from uploads
    * @param WP_REST_Request $request
    *
    * @return WP_REST_Response
    */
   public function handleBatchCreation(WP_REST_Request $request): WP_REST_Response
   {
      //Operation has two parts
      //First, queue image processing
      //Then queue post creation from the stored IDs, depending on mode
      //if direct, each image becomes a new post
      //if selection, each group becomes its own post,
      // and ungrouped items each become their own post
      if (!isset($_FILES['files'])) {
         return new WP_REST_Response([
            'success' => false,
            'message' => 'No files uploaded...',
         ]);
      }
      $data = $request->get_params();
      $user_id = $data['user'];
      if (!$this->userCheck($user_id)) {
         return new WP_REST_Response([
            'success' => 'false',
            'message' => 'Invalid user match... are you a bot?'
         ]);
      }
      $operation_id = $data['id'];
      $response = new WP_REST_Response([
         'success' => true,
         'message' => 'Successfully sent to server. Added to queue.',
         'operation_id' => $operation_id,
         'status' => 'pending'
      ]);
      $this->queue = JVB()->queue();
      JVB()->routes('uploads')->handleUploadRequest($request, false);
      $this->queue->queueOperation(
         'batch_creation',
         $user_id,
         [
            'content' => $request->get_param('content'),
            'mode' => $request->get_param('mode') ?: 'direct',
            'files_data' => $request->get_param('files_data')
         ],
         [
            'operation_id' => $operation_id,
            'priority' => 'high',
            'notification' => true,
            'depends_on' => $operation_id . '_upload'
         ]
      );
      return $response;
   }
   /**
    * Generates a post title, based on content type
@@ -926,13 +516,15 @@
    *
    * @return array
    */
   protected function prepareItem(WP_Post $post, bool $skip = false, bool $fields = true): array
   protected function preparePost(WP_Post $post, bool $skip = false, bool $fields = true): array
   {
      if (!$skip && Features::forContent($post->post_type)->has('is_timeline')) {
      $registrar = Registrar::getInstance($post->post_type);
      if (!$skip && $registrar && $registrar->hasFeature('is_timeline')) {
         $this->initTimelineFields($post->post_type);
         return $this->formatTimeline($post);
      }
      $this->meta = new MetaManager($post->ID, 'post');
      $this->meta = Meta::forPost($post->ID);
      $fields = ($fields) ? $this->meta->getAll() : [];
      $data = [
         'id' => $post->ID,
         'title' => $post->post_title,
@@ -941,68 +533,68 @@
         'modified' => $post->post_modified,
         'thumbnail' => get_the_post_thumbnail_url($post->ID),
         'alt' => get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true),
         'icon' => $this->post_type,
         'icon' => $registrar->getIcon(),
         'taxonomies' => [],
         'fields' => ($fields) ? $this->meta->getAll() : [],
         'fields' => $fields,
         'images' => [],
      ];
      // Add taxonomy terms
      foreach ($this->taxonomies as $taxonomy => $options) {
         $tax = str_replace(BASE, '', $taxonomy);
         $terms = wp_get_object_terms(
            $post->ID,
            $taxonomy,
            ['fields' => 'id=>name']
         );
         $data['taxonomies'][$tax] = [
            'terms' => (is_wp_error($terms)) ? [] : $terms,
            'name' => $options['label'],
            'icon' => $tax
         ];
      }
      $images = $this->extractImages();
      $images = $this->extractImages($fields, $this->meta);
      if (!empty($images)) {
         $data['images'] = $images;
      }
      $taxonomies = $this->extractTerms($fields, $this->meta);
      if (!empty($taxonomies)) {
         $data['taxonomies'] = $taxonomies;
      }
      return $data;
   }
   protected function extractImages(array $fields = []): array
   /**
    * @param WP_Term $post the post object
    *
    * @return array
    */
   protected function prepareTerm(WP_Term $post, bool $fields = true): array
   {
      //Extract images
      $images = [];
      $get = [];
      $fields = (empty($fields)) ? $this->fields : $fields;
      foreach ($fields as $field => $config) {
         if ($config['type'] === 'gallery' || $config['type'] === 'image' || $field === 'post_thumbnail') {
            $get[] = $field;
         }
      $registrar = Registrar::getInstance($post->taxonomy);
      $this->meta = Meta::forTerm($post->term_id);
      $fields = ($fields) ? $this->meta->getAll() : [];
      $data = [
         'id' => $post->term_id,
         'title' => $post->name,
         'date' => $fields['created_date']??'',
         'modified' => $fields['modified_date']??'',
         'thumbnail' => '',
         'icon' => $registrar->getIcon(),
         'taxonomies' => [],
         'fields' => $fields,
         'images' => [],
      ];
      $images = $this->extractImages($fields, $this->meta);
      if (!empty($images)) {
         $data['images'] = $images;
      }
      if (!empty($get)) {
         $allImages = $this->meta->getAll($get);
         foreach ($allImages as $k => $imgs) {
            $temp = explode(',', $imgs);
            foreach ($temp as $img) {
               if (is_numeric($img) && !array_key_exists($img, $images)) {
                  $images[$img] = jvbImageData((int)$img);
               }
            }
         }
      $taxonomies = $this->extractTerms($fields, $this->meta);
      if (!empty($taxonomies)) {
         $data['taxonomies'] = $taxonomies;
      }
      return $images;
      error_log('Term data: '.print_r($data, true));
      return $data;
   }
   public function formatTimeline(WP_Post $post): array
   {
      $item = $this->prepareItem($post, true, false);
      $item = $this->preparePost($post, true, false);
      //Step 1: Get the fields that apply to all posts
      $mainMeta = new MetaManager($post->ID, 'post');
      $mainMeta = Meta::forPost($post->ID);
      $item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
      //Step 2: Get the fields for each individual posts
@@ -1012,300 +604,17 @@
      $subFields = [];
      $images = [];
      foreach ($children as $child) {
         $meta = new MetaManager($child, 'post');
         $meta = Meta::forPost($child);
         $f = $meta->getAll($this->timelineUniqueFields);
         $f = ['id' => $child] + $f;
         $subFields[] = $f;
         $images[$f['post_thumbnail']] = jvbImageData((int)$f['post_thumbnail']);
      }
      $item['fields']['timeline'] = $subFields;
      $item['fields']['timeline_gallery'] = $subFields;
      $item['images'] = $item['images'] + $images;
      $item['number'] = $mainMeta->getValue('number');
      $item['number'] = $mainMeta->get('number');
      return $item;
   }
   /**
    * Builds the taxonomy query
    * @param array $taxonomies
    *
    * @return array|string[]
    */
   protected function buildTaxQuery(array $taxonomies): array
   {
      $tax_query = [];
      error_log('Taxonomies in query: ' . print_r($taxonomies, true));
      foreach ($taxonomies as $taxonomy => $terms) {
         if (!empty($terms)) {
            $tax_query[] = [
               'taxonomy' => jvbCheckBase($taxonomy),
               'field' => 'term_id',
               'terms' => array_map('absint', (array)$terms)
            ];
         }
      }
      return count($tax_query) > 1
         ? array_merge(['relation' => 'AND'], $tax_query)
         : $tax_query;
   }
   /**
    * Builds the date query
    * @param array $date_params
    *
    * @return array
    */
   protected function buildDateQuery(array $date_params): array
   {
      $query = [];
      if (!empty($date_params['after'])) {
         $query['after'] = sanitize_text_field($date_params['after']);
      }
      if (!empty($date_params['before'])) {
         $query['before'] = sanitize_text_field($date_params['before']);
      }
      if (isset($date_params['inclusive'])) {
         $query['inclusive'] = (bool)$date_params['inclusive'];
      }
      return empty($query) ? [] : [$query];
   }
   /**
    * @param int $post_id
    *
    * @return bool
    */
   protected function verifyOwnership(int $post_id): bool
   {
      $post = get_post($post_id);
      return $post && $post->post_author == $this->user_id;
   }
   /**
    * Processes operation from Operation Queue
    * @param WP_Error|array $result
    * @param object $operation
    * @param array $data
    *
    * @return array|WP_Error
    */
   public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error
   {
      if ($operation->type === 'batch_creation') {
         $JVB = JVB();
         $queue = $JVB->queue();
         $images = $queue->getOperationValue($operation->id . '_upload', 'result') ?? false;
         $this->user_id = $operation->user_id;
         $this->post_type = BASE . $data['content'];
         try {
            $results = [];
            if ($images) {
               if ($data['mode'] == 'selection') {
                  $total = count($images);
                  foreach ($images as $group => $files) {
                     $settings = json_decode($data['files_data'][$group]);
                     switch ($settings->type) {
                        case 'group':
                           $featuredIndex = $settings->metadata->featuredFile ?? 0;
                           $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']);
                           $new = wp_insert_post([
                              'post_type' => BASE . $data['content'],
                              'post_title' => $title,
                              'post_status' => 'draft',
                              'post_author' => $operation->user_id
                           ]);
                           if ($new && !is_wp_error($new)) {
                              set_post_thumbnail($new, $files[$featuredIndex]['attachment_id']);
                              unset($files[$featuredIndex]);
                              if (!empty($files)) {
                                 $meta = new MetaManager($new, 'post');
                                 $IDs = array_column($files, 'attachment_id');
                                 $meta->updateValue('gallery', implode(',', $IDs));
                              }
                              $results[] = $new;
//                                        $queue->updateOperationProgress($operation->id, $group + 1, $total);
                           }
                           break;
                        default:
                           foreach ($files as $img) {
                              $new = wp_insert_post([
                                 'post_type' => BASE . $data['content'],
                                 'post_title' => $this->generatePostTitle($data['content']),
                                 'post_status' => 'draft',
                                 'post_author' => $operation->user_id
                              ]);
                              if ($new && !is_wp_error($new)) {
                                 set_post_thumbnail($new, $img['attachment_id']);
                                 $results[] = $new;
//                                            $queue->updateOperationProgress($operation->id, $group + 1, $total);
                              }
                           }
                           break;
                     }
                  }
               } else {
                  $total = count($images);
                  foreach ($images as $key => $img) {
                     $new = wp_insert_post([
                        'post_type' => BASE . $data['content'],
                        'post_title' => $this->generatePostTitle($data['content']),
                        'post_status' => 'draft',
                        'post_author' => $operation->user_id
                     ]);
                     if ($new && !is_wp_error($new)) {
                        set_post_thumbnail($new, $img['attachment_id']);
                     }
                     $results[] = $new;
//                            $queue->updateOperationProgress($operation->id, $key + 1, $total);
                  }
               }
            }
            return [
               'success' => true,
               'result' => $results
            ];
         } catch (Exception $e) {
            $JVB->error()->log(
               '[ContentRoutes]:processOperation',
               $e->getMessage()
            );
         }
         return $results;
      } elseif ($operation->type == 'content_update') {
         $result = $this->processBatches($operation, $data);
      }
      return $result;
   }
   // Add to ContentRoutes.php
   /**
    * One-time migration: Set latest_date meta for all timeline posts
    * Call this once via WP-CLI or a temporary admin page
    *
    * Usage: add_action('admin_init', function() {
    *     if (current_user_can('manage_options')) {
    *         JVB()->routes('content')->migrateTimelineLatestDates();
    *     }
    * });
    */
   public function migrateTimelineLatestDates(): array
   {
      global $wpdb;
      $results = [
         'processed' => 0,
         'updated' => 0,
         'skipped' => 0,
         'errors' => []
      ];
      // Get all timeline post types
      $timeline_types = [];
      foreach (JVB_CONTENT as $type => $config) {
         if (Features::forContent($type)->has('is_timeline')) {
            $timeline_types[] = BASE . $type;
         }
      }
      if (empty($timeline_types)) {
         return $results;
      }
      // Get all parent timeline posts
      $args = [
         'post_type' => $timeline_types,
         'post_status' => ['publish', 'draft'],
         'post_parent' => 0,
         'posts_per_page' => -1,
         'fields' => 'ids'
      ];
      $parent_ids = get_posts($args);
      foreach ($parent_ids as $parent_id) {
         $results['processed']++;
         try {
            // Get all children including the parent
            $children = get_children([
               'post_parent' => $parent_id,
               'post_status' => ['publish', 'draft'],
               'orderby' => 'menu_order',
               'order' => 'ASC',
               'fields' => 'ids'
            ]);
            // Add parent to the list
            array_unshift($children, $parent_id);
            // Find latest date among all posts
            $latest_timestamp = 0;
            foreach ($children as $post_id) {
               $date = get_post_meta($post_id, BASE . 'date', true);
               if ($date) {
                  $timestamp = strtotime($date);
                  if ($timestamp > $latest_timestamp) {
                     $latest_timestamp = $timestamp;
                  }
               }
            }
            // Update parent with latest date
            if ($latest_timestamp > 0) {
               update_post_meta($parent_id, BASE . 'latest_date', $latest_timestamp);
               $results['updated']++;
               error_log("Updated post {$parent_id} with latest_date: {$latest_timestamp}");
            } else {
               // Fallback to parent post's post_date
               $parent_post = get_post($parent_id);
               $fallback_timestamp = strtotime($parent_post->post_date);
               if ($fallback_timestamp > 0) {
                  update_post_meta($parent_id, BASE . 'latest_date', $fallback_timestamp);
                  $results['updated']++;
                  error_log("Updated post {$parent_id} with fallback latest_date: {$fallback_timestamp} (from post_date)");
               } else {
                  $results['skipped']++;
                  error_log("No dates found for post {$parent_id}");
               }
            }
         } catch (Exception $e) {
            $results['errors'][] = [
               'post_id' => $parent_id,
               'error' => $e->getMessage()
            ];
         }
      }
      error_log('Timeline migration complete: ' . print_r($results, true));
      return $results;
   }
}
//add_action('init', function() {
////  delete_option('jvb_timeline_migrated');
// if (get_option('jvb_timeline_migrated')) {
//    return;
// }
// JVB()->routes('content')->migrateTimelineLatestDates();
// update_option('jvb_timeline_migrated', true);
//});