>cache->invalidateGroup($this->>cache_name)) on content update/create //TODO: Also invalidate feed caches on updates!! public function __construct() { $this->cache_name = 'user_content_'.get_current_user_id(); parent::__construct(); $this->action = 'dash-'; $this->operation_type = 'content_update'; add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } /** * 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'] ] ]); } /** * 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(); error_log('Received data: '.print_r($data, true)); $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']); $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(); error_log('handleContentRequest params: '.print_r($params, true)); error_log('Fetching content. Params: '.print_r($params, true)); $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'])); $config = Features::getConfig($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); } if (array_key_exists('taxonomies', $params)) { $args = $this->applyTaxonomyFilters($args, $params); } if (array_key_exists('date', $params) && !empty($params['date'])) { $args = $this->applyDateFilters($args, $params); } if (array_key_exists('orderby', $params) || array_key_exists('order', $params)) { $args = $this->applyOrderFilters($args, $params); } if (!empty($params['search'])) { $args['s'] = sanitize_text_field($params['search']); } error_log('Content Routes final args: '.print_r($args, true)); $key = $this->cache->generateKey($args); // Check HTTP cache headers with the specific content type $content_type = $params['content'] ?? $params['type']; $cache_check = $this->checkHeaders($request, $content_type, [ 'filter_hash' => $key, ]); if ($cache_check) { return $cache_check; } $cache = $this->cache->get($key); $cache = false; if ($cache) { $response = new WP_REST_Response($cache); return $this->addCacheHeaders($response); } // Run query $query = new WP_Query($args); $this->post_type = $params['content']??$params['type']; $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); } /** * 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')) { $results[$ID] =$this->processTimelinePost($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); error_log('Should be set?'); $results[$ID] = [ 'success' => $success ]; } CacheManager::invalidateGroup($post_data['content']); if (jvbSiteUsesFeedBlock()) { CacheManager::invalidateGroup($post_data['feed']); } } CacheManager::invalidateGroup('user_content'); 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']; } $rows = $post_data['fields'] ?? []; if (empty($rows)) { return ['success' => false, 'message' => 'No data']; } $fields = jvbGetFields($post_data['content']); // First row = parent post $parent_row = array_shift($rows); if (($parent_row['id'] ?? null) != $parent_id) { return ['success' => false, 'message' => 'Parent ID mismatch']; } $allowedFields = array_filter($parent_row, function($key) use ($fields) { return array_key_exists($key, $fields); }, ARRAY_FILTER_USE_KEY); $parentMeta = new MetaManager($parent_id, 'post'); $parentMeta->setAll($allowedFields); // Get existing children to track deletions $existing_children = get_children([ 'post_parent' => $parent_id, 'post_type' => jvbCheckBase($post_data['content']), 'fields' => 'ids' ]); $processed_ids = []; // Process remaining rows as children foreach ($rows as $index => $row_data) { $row_id = $row_data['id'] ?? null; // New child post if (!$row_id || str_starts_with($row_id, 'new')) { $child_id = wp_insert_post([ 'post_type' => jvbCheckBase($post_data['content']), 'post_parent' => $parent_id, 'post_author' => $this->user_id, 'post_status' => $post_data['status'] ?? 'draft', 'menu_order' => $index ]); } // Existing child post else { $child_id = (int) $row_id; // Verify ownership via parent if (!in_array($child_id, $existing_children)) { continue; // Skip if not actually a child of this parent } // Update menu_order (position may have changed) wp_update_post([ 'ID' => $child_id, 'menu_order' => $index ]); } // Update child meta $allowedChildFields = array_filter($row_data, function($key) use ($fields) { return array_key_exists($key, $fields) && $key !== 'id' && $key !== 'draggable'; }, ARRAY_FILTER_USE_KEY); $childMeta = new MetaManager($child_id, 'post'); $childMeta->setAll($allowedChildFields); $processed_ids[] = $child_id; } // Delete removed children $deleted_ids = array_diff($existing_children, $processed_ids); foreach ($deleted_ids as $delete_id) { wp_delete_post($delete_id, true); } return ['success' => true, 'processed' => $processed_ids]; } /** * 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 wordpress post object * * @return array */ protected function prepareItem(WP_Post $post, $skip = false):array { if (!$skip && Features::forContent($post->post_type)->has('is_timeline')) { return $this->formatTimeline($post); } $this->meta = new MetaManager($post->ID, 'post'); $data = [ 'id' => $post->ID, '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' => $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 { //Extract images $images = []; $get = []; foreach ($this->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; } protected function formatTimeline(WP_Post $post):array { $data = $this->prepareItem($post, true); $firstRow = $data['fields']; $firstRow['id'] = $post->ID; $firstRow['draggable'] = false; $fields = [$firstRow]; $children = get_children(['post_parent' => $post->ID, 'orderby' => 'menu_order']); $allImages = []; foreach ($children as $child) { $this->meta = new MetaManager($child->ID, 'post'); $row = $this->meta->getAll(); // Store in variable first $row['id'] = $child->ID; // Add ID to the row $row['draggable'] = true; // Mark as draggable $fields[] = $row; // Then append to fields $images = $this->extractImages(); if (!empty($images)) { $allImages = $allImages + $images; } } if (!empty($allImages)) { if (!array_key_exists('images', $data)) { $data['images'] = []; } $data['images'] = $data['images'] + $allImages; } $data['fields']['timeline'] = $fields; return $data; } /** * 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); } } //Clear cache CacheManager::invalidateGroup($data['content']); CacheManager::invalidateGroup('feed'); CacheManager::invalidateGroup('user_content'); } 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; } }