type, self::HANDLED_TYPES)) { throw new Exception("ContentExecutor cannot handle type: {$operation->type}"); } $this->userId = $operation->userId; try { $data = $operation->requestData; $result = match($operation->type) { 'content_update' => $this->processContentUpdate($operation, $data, $progress), 'batch_creation' => $this->processBatchCreation($operation, $data, $progress), default => throw new Exception("Unknown type: {$operation->type}") }; return $result; } catch (Exception $e) { JVB()->error()->log( '[ContentExecutor]:execute', $e->getMessage(), [ 'operation_id' => $operation->id, 'operation_type' => $operation->type, 'user_id' => $operation->userId, ] ); return new Result( outcome: 'failed', result: ['error' => $e->getMessage()] ); } } // ───────────────────────────────────────────────────────────── // Content Update // ───────────────────────────────────────────────────────────── private function processContentUpdate(Operation $operation, array $data, Progress $progress): Result { $posts = $data['posts'] ?? []; if (empty($posts)) { return new Result( outcome: 'failed', result: ['message' => 'No posts to update'] ); } $results = []; $errors = []; $updateTimelineOrder = []; foreach ($posts as $id => $postData) { try { $content = $postData['content'] ?? ''; // New post creation if (str_starts_with((string)$id, 'new')) { $newId = wp_insert_post([ 'post_author' => $this->userId, 'post_type' => jvbCheckBase($content), 'post_title' => $postData['post_title'] ?? '', 'post_status' => $postData['status'] ?? 'draft', ]); if (!$newId || is_wp_error($newId)) { $progress->failItem($id, 'Could not create post'); continue; } $this->savePostFields($newId, $postData); $results[$id] = ['success' => true, 'new_id' => $newId]; if (Features::forContent($content)->has('is_timeline')) { $this->updateTimelineLatestDate($newId); } $progress->advance(1); continue; } // Existing post update if (!$this->verifyOwnership((int)$id)) { $progress->failItem($id, 'No permission to modify this post'); continue; } // Check if this is a timeline post $isTimeline = Features::forContent($content)->has('is_timeline'); if ($isTimeline) { $post = get_post((int)$id); $parentId = $post->post_parent; $isParent = ($parentId === 0); // Track timeline reordering only if date changed if (array_key_exists('post_date', $postData)) { $timelineRoot = $isParent ? (int)$id : $parentId; if (!in_array($timelineRoot, $updateTimelineOrder)) { $updateTimelineOrder[] = $timelineRoot; } } // Update shared fields if this is the parent if ($isParent) { $this->initTimelineFields($content); $sharedFieldsUpdated = array_filter($postData, function($key) { return in_array($key, $this->timelineSharedFields); }, ARRAY_FILTER_USE_KEY); if (!empty($sharedFieldsUpdated)) { $this->updateSharedFields((int)$id, $sharedFieldsUpdated); } } } $this->processPostUpdate((int)$id, $postData); if (Features::forContent($content)->has('is_timeline') && array_key_exists('post_date', $postData)) { $post = get_post((int)$id); $parentId = $post->post_parent === 0 ? (int)$id : $post->post_parent; $this->updateTimelineLatestDate($parentId); } $results[$id] = ['success' => true]; $progress->advance(1); // Clear caches CacheManager::for($content)->clear(); if (jvbSiteUsesFeedBlock()) { CacheManager::for('feed')->clear(); } } catch (Exception $e) { $progress->failItem($id, $e->getMessage()); $errors[$id] = $e->getMessage(); } } if (!empty($updateTimelineOrder)) { foreach ($updateTimelineOrder as $parentID) { $this->reorderTimelineByDate($parentID); } } // Send notification if (jvbSiteHasNotifications()) { JVB()->notification()->addNotification( $this->userId, 'content_update_complete', null, 'Content updates completed!' ); } $outcome = 'success'; if (!empty($errors)) { $outcome = count($errors) === count($posts) ? 'failed' : 'partial'; } return new Result( outcome: $outcome, result: $results ); } private function processPostUpdate(int $postId, array $postData): void { $content = $postData['content'] ?? ''; // Handle status changes if (isset($postData['post_status'])) { switch ($postData['post_status']) { case 'publish': if (user_can($this->userId, 'manage_options') || user_can($this->userId, 'skip_moderation')) { wp_update_post(['ID' => $postId, 'post_status' => 'publish']); } unset($postData['post_status']); break; case 'draft': wp_update_post(['ID' => $postId, 'post_status' => 'draft']); unset($postData['post_status']); break; case 'trash': wp_trash_post($postId); return; case 'delete': wp_delete_post($postId, true); return; } } // Save all fields via MetaManager (handles post fields too) $this->savePostFields($postId, $postData); } private function updateSharedFields(int $parentId, array $sharedFields): void { // Get all posts in timeline $children = get_posts([ 'post_type' => get_post_type($parentId), 'post_parent' => $parentId, 'post_status' => ['publish', 'draft'], 'posts_per_page' => -1, 'fields' => 'ids' ]); $allPostIds = array_merge([$parentId], $children); // Apply shared fields to all posts foreach ($allPostIds as $timelinePostId) { $meta = new MetaManager($timelinePostId, 'post'); $meta->setAll($sharedFields); } } private function reorderTimelineByDate(int $parentId): void { $parent = get_post($parentId); if (!$parent) return; // Get all posts in this timeline (parent + children) $children = get_posts([ 'post_type' => get_post_type($parentId), 'post_parent' => $parentId, 'post_status' => ['publish', 'draft'], 'posts_per_page' => -1, 'orderby' => 'date', 'order' => 'ASC' ]); // Combine and sort by post_date $allPosts = array_merge([$parent], $children); usort($allPosts, function($a, $b) { return strtotime($a->post_date) <=> strtotime($b->post_date); }); $newParent = $allPosts[0]; // If parent changed, restructure if ($newParent->ID !== $parentId) { wp_update_post([ 'ID' => $newParent->ID, 'post_parent' => 0, 'menu_order' => 0 ]); wp_update_post([ 'ID' => $parentId, 'post_parent' => $newParent->ID ]); foreach ($allPosts as $index => $post) { if ($index === 0) continue; wp_update_post([ 'ID' => $post->ID, 'post_parent' => $newParent->ID, 'menu_order' => $index ]); $this->getOrCreateTerm($post->ID, (string)$index, 'number'); } } else { // Just update menu_order foreach ($allPosts as $index => $post) { if ($index === 0) continue; wp_update_post([ 'ID' => $post->ID, 'menu_order' => $index ]); $this->getOrCreateTerm($post->ID, (string)$index, 'number'); } } // Calculate and set timeline taxonomy (time since previous post) $previousPost = null; foreach ($allPosts as $index => $post) { if ($index === 0) { // Parent post - no timeline term (it's the baseline) wp_set_object_terms($post->ID, [], BASE . 'timeline', false); $previousPost = $post; continue; } $timelineTerm = $this->calculateTimelineTerm($previousPost, $post); if ($timelineTerm) { $this->getorCreateTerm($post->ID, $timelineTerm, 'timeline'); } $previousPost = $post; } $this->updateTimelineLatestDate($newParent->ID); } private function updateTimelineLatestDate(int $parentId): void { $parent = get_post($parentId); if (!$parent) return; // Get all posts in timeline $children = get_posts([ 'post_type' => get_post_type($parentId), 'post_parent' => $parentId, 'post_status' => ['publish', 'draft'], 'posts_per_page' => -1, 'orderby' => 'date', 'order' => 'DESC', // Get newest first 'fields' => 'ids' ]); $allPostIds = array_merge([$parentId], $children); // Get all timestamps $timestamps = array_map(function($id) { $post = get_post($id); return $post ? strtotime($post->post_date) : 0; }, $allPostIds); $latestTimestamp = max($timestamps); // Store as UNIX timestamp update_post_meta($parentId, BASE . 'latest_date', $latestTimestamp); } private function calculateTimelineTerm(\WP_Post $previousPost, \WP_Post $currentPost): ?string { $previousDate = strtotime($previousPost->post_date); $currentDate = strtotime($currentPost->post_date); if (!$previousDate || !$currentDate || $currentDate <= $previousDate) { return null; } // Calculate difference in days $daysDiff = floor(($currentDate - $previousDate) / (60 * 60 * 24)); // Convert to weeks $weeks = floor($daysDiff / 7); // If less than 16 weeks, use weeks if ($weeks < 16) { if ($weeks === 0) { return null; // Same week, no term } return $weeks === 1 ? '1 Week' : $weeks . ' Weeks'; } // 16+ weeks, calculate months // Using actual month calculation rather than weeks/4 $previousDateTime = new \DateTime($previousPost->post_date); $currentDateTime = new \DateTime($currentPost->post_date); $interval = $previousDateTime->diff($currentDateTime); $months = ($interval->y * 12) + $interval->m; if ($months === 0) { // Edge case: technically less than a full month but 16+ weeks return $weeks . ' Weeks'; } return $months === 1 ? '1 Month' : $months . ' Months'; } private function getOrCreateTerm(int $postID, string $termName, string $taxonomy): void { $taxonomy = jvbCheckBase($taxonomy); $term = get_term_by('name', $termName, $taxonomy); if (!$term) { $result = wp_insert_term($termName, $taxonomy); if (is_wp_error($result)) { return; } $termID = $result['term_id']; } else { $termID = $term->term_id; } if ($termID) { wp_set_object_terms($postID, [$termID], $taxonomy, false); } } private function savePostFields(int $postId, array $postData): bool { $content = $postData['content'] ?? ''; $fields = jvbGetFields($content); $allowedFields = array_filter($postData, function ($key) use ($fields) { return array_key_exists($key, $fields); }, ARRAY_FILTER_USE_KEY); if (empty($allowedFields)) { return true; } $meta = new MetaManager($postId, 'post'); return $meta->setAll($allowedFields); } // ───────────────────────────────────────────────────────────── // Batch Creation // ───────────────────────────────────────────────────────────── private function processBatchCreation(Operation $operation, array $data, Progress $progress): Result { $this->postType = BASE . $data['content']; // Get upload results from dependency $uploadOpId = $operation->id . '_upload'; $images = JVB()->queue()->get($uploadOpId)?->result ?? null; if (!$images) { return new Result( outcome: 'failed', result: ['message' => 'No upload results found'] ); } $results = []; if ($data['mode'] === 'selection') { $results = $this->createFromSelection($operation, $data, $images, $progress); } else { $results = $this->createFromDirect($operation, $data, $images, $progress); } // Clear caches CacheManager::for($data['content'])->clear(); CacheManager::for('feed')->clear(); return new Result( outcome: !empty($results) ? 'success' : 'failed', result: $results ); } private function createFromSelection(Operation $operation, array $data, array $images, Progress $progress): array { $results = []; foreach ($images as $group => $files) { $settings = json_decode($data['files_data'][$group] ?? '{}'); if (($settings->type ?? '') === 'group') { $postId = $this->createGroupPost($operation, $data, $files, $settings); } else { $postId = $this->createIndividualPosts($operation, $data, $files); } if ($postId) { $results = array_merge($results, (array)$postId); } $progress->advance(1); } return $results; } private function createFromDirect(Operation $operation, array $data, array $images, Progress $progress): array { $results = []; foreach ($images as $img) { $postId = wp_insert_post([ 'post_type' => $this->postType, 'post_title' => $this->generatePostTitle($data['content']), 'post_status' => 'draft', 'post_author' => $operation->userId, ]); if ($postId && !is_wp_error($postId)) { set_post_thumbnail($postId, $img['attachment_id']); $results[] = $postId; } $progress->advance(1); } return $results; } private function createGroupPost(Operation $operation, array $data, array $files, object $settings): ?int { $featuredIndex = $settings->metadata->featuredFile ?? 0; $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']); $postId = wp_insert_post([ 'post_type' => $this->postType, 'post_title' => $title, 'post_status' => 'draft', 'post_author' => $operation->userId, ]); if (!$postId || is_wp_error($postId)) { return null; } // Set featured image set_post_thumbnail($postId, $files[$featuredIndex]['attachment_id']); // Remaining files go to gallery unset($files[$featuredIndex]); if (!empty($files)) { $meta = new MetaManager($postId, 'post'); $ids = array_column($files, 'attachment_id'); $meta->updateValue('gallery', implode(',', $ids)); } return $postId; } private function createIndividualPosts(Operation $operation, array $data, array $files): array { $results = []; foreach ($files as $img) { $title = $this->generatePostTitle($data['content']); $postId = wp_insert_post([ 'post_type' => $this->postType, 'post_title' => $title, 'post_slug' => sanitize_title($title), 'post_status' => 'draft', 'post_author' => $operation->userId, ]); if ($postId && !is_wp_error($postId)) { set_post_thumbnail($postId, $img['attachment_id']); $results[] = $postId; } } return $results; } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── private function initTimelineFields(string $content): void { $content = jvbNoBase($content); if (!Features::forContent($content)->has('is_timeline')) { return; } $config = Features::getConfig($content); $this->fields = $config['fields'] ?? []; // Shared fields (apply to all posts) $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) { return !isset($field['for_all']) || $field['for_all'] === false; })); array_unshift($this->timelineSharedFields, 'post_thumbnail', 'post_title', 'post_status'); // Unique fields (per-entry) $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) { return isset($field['for_all']) && $field['for_all'] === true; })); } private function verifyOwnership(int $postId): bool { $post = get_post($postId); return $post && (int)$post->post_author === $this->userId; } private function generatePostTitle(string $content): string { $username = get_user_meta($this->userId, 'first_name', true); $link = get_user_meta($this->userId, BASE . 'link', true); $city = function_exists('jvbArtistCity') ? jvbArtistCity($link) : ''; return ucfirst($content) . ' by ' . ($city ? "$city artist " : '') . $username; } }