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 = []; foreach ($posts as $id => $postData) { try { $content = $postData['content'] ?? ''; // Timeline posts if (Features::forContent($content)->has('is_timeline') && isset($postData['timeline'])) { $parentId = (int)$id; if ($parentId === 0) { $progress->failItem($id, 'Invalid parent post ID for timeline'); continue; } $results[$id] = $this->processTimelinePost($parentId, $postData); $progress->advance(1); continue; } // New post creation if (str_starts_with((string)$id, 'new')) { $newId = wp_insert_post([ '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]; $progress->advance(1); continue; } // Existing post update if (!$this->verifyOwnership((int)$id)) { $progress->failItem($id, 'No permission to modify this post'); continue; } $this->processPostUpdate((int)$id, $postData); $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(); } } // 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']); break; case 'trash': wp_trash_post($postId); return; case 'delete': wp_delete_post($postId, true); return; } } $this->savePostFields($postId, $postData); } 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) { $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; } } return $results; } // ───────────────────────────────────────────────────────────── // Timeline Processing // ───────────────────────────────────────────────────────────── private function processTimelinePost(int $parentId, array $postData): array { if (!$this->verifyOwnership($parentId)) { return ['success' => false, 'message' => 'No permission']; } $content = $postData['content']; $this->initTimelineFields($content); $parentPost = get_post($parentId); $parentIsPublished = ($parentPost->post_status === 'publish'); // Extract shared data (excluding post_thumbnail) $sharedData = array_filter($postData, function ($key) { return in_array($key, $this->timelineSharedFields) && !in_array($key, ['content', 'user']) && $key !== 'post_thumbnail'; }, ARRAY_FILTER_USE_KEY); if (!isset($sharedData['post_title']) && isset($postData['timeline'][0]['post_title'])) { $sharedData['post_title'] = $postData['timeline'][0]['post_title']; } if (!isset($postData['timeline']) || !is_array($postData['timeline'])) { return ['success' => false, 'message' => 'No timeline data']; } // Validate parent is in timeline $index = array_search((string)$parentId, array_column($postData['timeline'], 'id')); if ($index === false) { return ['success' => false, 'message' => 'Missing parent id']; } // Handle parent reordering if needed if ($index !== 0) { $parentId = $this->reorderTimelineParent($parentId, $postData['timeline'], $index); $parentPost = get_post($parentId); $parentIsPublished = ($parentPost->post_status === 'publish'); } // Shared taxonomies (excluding title and thumbnail) $sharedTaxonomies = array_filter($sharedData, function ($key) { return !in_array($key, ['post_title', 'post_thumbnail']); }, ARRAY_FILTER_USE_KEY); $existingChildren = get_children([ 'post_parent' => $parentId, 'orderby' => 'menu_order', 'post_status' => ['publish', 'draft'], 'fields' => 'ids', ]); $errors = []; $success = []; foreach ($postData['timeline'] as $order => $timeline) { $result = $this->processTimelineEntry( $timeline, $order, $parentId, $parentIsPublished, $sharedTaxonomies, $existingChildren, $content ); if ($result['success']) { $success[] = $result; if (isset($result['child_id']) && in_array($result['child_id'], $existingChildren)) { unset($existingChildren[array_search($result['child_id'], $existingChildren)]); } } else { $errors[] = $result; } } // Trash orphaned children foreach ($existingChildren as $orphanId) { wp_trash_post($orphanId); } return [ 'success' => empty($errors), 'updated' => count($success), 'errors' => $errors, ]; } private function processTimelineEntry( array $timeline, int $order, int $parentId, bool $parentIsPublished, array $sharedTaxonomies, array &$existingChildren, string $content ): array { $isParent = ((int)($timeline['id'] ?? 0) === $parentId); // Get unique fields for this entry $allowedFields = array_filter($timeline, function ($key) { return in_array($key, $this->timelineUniqueFields) && !in_array($key, ['content', 'user']); }, ARRAY_FILTER_USE_KEY); // Determine title $providedTitle = $timeline['post_title'] ?? ''; $autoPattern = '/^.+Treatment #?\d+$/'; if ($isParent) { $allowedFields['post_title'] = $providedTitle ?: ($sharedTaxonomies['post_title'] ?? get_post($parentId)->post_title); } else { if (empty($providedTitle) || preg_match($autoPattern, $providedTitle)) { $allowedFields['post_title'] = 'Treatment ' . $order; } else { $allowedFields['post_title'] = $providedTitle; } } $allowedFields = array_merge($sharedTaxonomies, $allowedFields); // Create child if needed $childId = $timeline['id'] ?? null; if (!$childId || !is_numeric($childId)) { $childId = wp_insert_post([ 'post_author' => $this->userId, 'post_type' => jvbCheckBase($content), 'post_title' => $allowedFields['post_title'], 'post_parent' => $parentId, 'menu_order' => $order, 'post_status' => $parentIsPublished ? 'publish' : 'draft', ]); if (!$childId || is_wp_error($childId)) { return ['success' => false, 'message' => 'Could not create child post']; } } // Update post $postUpdates = ['ID' => $childId]; if (!$isParent) { $postUpdates['menu_order'] = $order; if ($parentIsPublished) { $currentPost = get_post($childId); if ($currentPost && $currentPost->post_status !== 'publish') { $postUpdates['post_status'] = 'publish'; } } } if (isset($allowedFields['post_title'])) { $postUpdates['post_title'] = $allowedFields['post_title']; unset($allowedFields['post_title']); } wp_update_post($postUpdates); // Save meta fields if (!empty($allowedFields)) { $meta = new MetaManager($childId, 'post'); $meta->setAll($allowedFields); } return ['success' => true, 'child_id' => $childId]; } private function reorderTimelineParent(int $currentParentId, array $timeline, int $currentIndex): int { $newParentId = $timeline[0]['id'] ?? null; if (!is_numeric($newParentId) || (int)$newParentId <= 0) { return $currentParentId; } $newParentId = (int)$newParentId; // Make new parent a top-level post wp_update_post(['ID' => $newParentId, 'post_parent' => 0]); // Make old parent a child wp_update_post(['ID' => $currentParentId, 'post_parent' => $newParentId]); // Move existing children to new parent $existingChildren = get_children(['post_parent' => $currentParentId, 'fields' => 'ids']); foreach ($existingChildren as $childId) { if ($childId !== $newParentId) { wp_update_post(['ID' => $childId, 'post_parent' => $newParentId]); } } return $newParentId; } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── 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; } }