cache_name = 'user_content_' . get_current_user_id(); parent::__construct(); if (JVB_TESTING) { $this->cache->flush(); } $this->action = 'dash-'; $this->operation_type = 'content_update'; add_action('init', [$this, 'registerContentExecutors'], 5); } /** * Register content operation types with the queue's TypeRegistry */ public function registerContentExecutors(): void { $registry = JVB()->queue()->registry(); $executor = new ContentExecutor(); // Content updates - chunked at 10 posts $registry->register('content_update', new TypeConfig( executor: $executor, chunkKey: 'posts', chunkSize: 10 )); // Batch creation (from uploads) $registry->register('batch_creation', new TypeConfig( executor: $executor )); } /** * Registers content routes * @return void */ 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'] ] ]); } protected function initTimelineFields(string $content): void { $content = jvbNoBase($content); if (!Features::forContent($content)->has('is_timeline')) { return; } $config = Features::getConfig($content); $this->fields = $config['fields']; $this->timelineSharedFields = $this->getTimelineSharedFields($content); array_unshift($this->timelineSharedFields, 'post_thumbnail'); array_unshift($this->timelineSharedFields, 'post_title'); array_unshift($this->timelineSharedFields, 'post_status'); $this->timelineUniqueFields = $this->getTimelineUniqueFields($content); } public function getTimelineUniqueFields(string $content): array { $content = jvbNoBase($content); if (!Features::forContent($content)->has('is_timeline')) { return []; } $config = Features::getConfig($content); $allFields = $config['fields']; return array_keys(array_filter($allFields, function ($field) { if (array_key_exists('for_all', $field) && $field['for_all'] === true) { return true; } return false; })); } public function getTimelineSharedFields(string $content): array { $content = jvbNoBase($content); if (!Features::forContent($content)->has('is_timeline')) { return []; } $config = Features::getConfig($content); if (!$config || empty($config)) { return []; } $allFields = $config['fields'] ?? []; return array_keys(array_filter($allFields, function ($field) { if (!array_key_exists('for_all', $field) || $field['for_all'] === false) { return true; } return false; })); } /** * Handle content update/creation * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleContentUpdate(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 new WP_REST_Response([ 'success' => true, 'message' => 'No posts found' ]); } $count = count($data['posts']); $operationId = $data['id']; unset($data['user']); unset($data['id']); $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 ]); } /** * Handle request * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleContentRequest(WP_REST_Request $request): WP_REST_Response { $params = $request->get_params(); $user_id = $params['user']; if (!$this->userCheck($user_id)) { return new WP_REST_Response([ 'success' => false, 'message' => 'User does not match up. Are you a bot?', ]); } $post_status = $params['status']; if ($post_status === 'all') { $post_status = ['publish', 'draft']; } else { $post_status = explode(',', $post_status); } $post_type = str_replace('-', '_', jvbCheckBase($params['content'])); // Build query args $args = [ 'post_type' => $post_type, 'posts_per_page' => $params['per_page'] ?? 30, 'paged' => $params['page'], 'orderby' => 'date', 'order' => 'DESC', 'author' => $user_id, 'post_status' => $post_status ]; //Only top level posts for timeline types if (Features::forContent($post_type)->has('is_timeline')) { $args['post_parent'] = 0; } //Calendar filters if (Features::forContent($post_type)->has('is_calendar')) { $args = $this->applyCalendarFilters($args, $params); } $taxonomies = array_filter($params, function ($param) { return str_starts_with($param, 'tax_'); }, ARRAY_FILTER_USE_KEY); if (!empty($taxonomies)) { $params['taxonomies'] = []; foreach ($taxonomies as $taxonomy => $terms) { $taxonomy = str_replace('tax_', '', $taxonomy); $params['taxonomies'][$taxonomy] = $terms; } } 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 $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } $cache = $this->cache->get($key); if ($cache) { $response = new WP_REST_Response($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)) { $args = $this->applySearchFilters($args, $params); } // Run query $query = new WP_Query($args); $this->fields = jvbGetFields(str_replace('-', '_', $this->post_type)); $this->taxonomies = $this->getTaxonomies($this->post_type); $posts = array_map([$this, 'prepareItem'], $query->posts); $data = [ 'items' => $posts, 'total' => $query->found_posts, 'total_pages' => $query->max_num_pages ]; $this->cache->set($key, $data); $response = new WP_REST_Response($data); return $this->addCacheHeaders($response); } protected function applySearchFilters(array $args, array $params): array { $search_term = sanitize_text_field($params['search']); // Search term is already in $args['s'] from earlier // Get all taxonomies registered to this post type $taxonomies = get_object_taxonomies($this->post_type, 'names'); if (empty($taxonomies)) { return $args; } // Cache the taxonomy term lookup per search term + post type $term_cache_key = 'search_terms_' . md5($search_term . $this->post_type); $matching_term_ids = $this->cache->get($term_cache_key); if ($matching_term_ids === false) { $matching_term_ids = []; foreach ($taxonomies as $taxonomy) { $terms = get_terms([ 'taxonomy' => $taxonomy, 'search' => $search_term, 'hide_empty' => false, 'fields' => 'ids' ]); if (!is_wp_error($terms) && !empty($terms)) { $matching_term_ids = array_merge($matching_term_ids, $terms); } } // Cache term IDs for 1 hour $this->cache->set($term_cache_key, $matching_term_ids, 3600); } if (empty($matching_term_ids)) { return $args; } // Build tax_query for matching terms $term_queries = []; foreach ($taxonomies as $taxonomy) { $taxonomy_term_ids = array_filter($matching_term_ids, function ($term_id) use ($taxonomy) { $term = get_term($term_id); return !is_wp_error($term) && $term->taxonomy === $taxonomy; }); if (!empty($taxonomy_term_ids)) { $term_queries[] = [ 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => array_values($taxonomy_term_ids), 'operator' => 'IN' ]; } } if (!empty($term_queries)) { if (isset($args['tax_query'])) { $args['tax_query'] = [ 'relation' => 'OR', $args['tax_query'], [ 'relation' => 'OR', ...$term_queries ] ]; } else { $args['tax_query'] = [ 'relation' => 'OR', ...$term_queries ]; } } return $args; } /** * Gets allowed taxonomies for a particular content * @param string $content * * @return array */ 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, ]; } } 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' ]; } $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 ]; } /** * 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 * @param string $content the post type * * @return string */ protected function generatePostTitle(string $content): string { $username = get_user_meta($this->user_id, 'first_name', true); $link = get_user_meta($this->user_id, BASE . 'link', true); $city = jvbArtistCity($link); return ucfirst($content) . ' by ' . $city . ' artist ' . $username; } /** * @param WP_Post $post the post object * * @return array */ protected function prepareItem(WP_Post $post, bool $skip = false, bool $fields = true): array { if (!$skip && Features::forContent($post->post_type)->has('is_timeline')) { $this->initTimelineFields($post->post_type); return $this->formatTimeline($post); } $this->meta = new MetaManager($post->ID, 'post'); $data = [ 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'date' => $post->post_date, '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, 'taxonomies' => [], 'fields' => ($fields) ? $this->meta->getAll() : [], '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(); if (!empty($images)) { $data['images'] = $images; } return $data; } protected function extractImages(array $fields = []): 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; } } 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); } } } } return $images; } public function formatTimeline(WP_Post $post): array { $item = $this->prepareItem($post, true, false); //Step 1: Get the fields that apply to all posts $mainMeta = new MetaManager($post->ID, 'post'); $item['fields'] = $mainMeta->getAll($this->timelineSharedFields); //Step 2: Get the fields for each individual posts $children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish', 'draft'], 'fields' => 'ids']); array_unshift($children, $post->ID); $subFields = []; $images = []; foreach ($children as $child) { $meta = new MetaManager($child, 'post'); $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['images'] = $item['images'] + $images; $item['number'] = $mainMeta->getValue('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); //});