userId = $operation->userId; if (!in_array($operation->type, self::HANDLED_TYPES)) { throw new Exception("ContentExecutor cannot handle type: {$operation->type}"); } try { $data = $operation->requestData; return match($operation->type) { 'content_update' => $this->processContentUpdate($operation, $data, $progress), default => throw new Exception("Unknown type: {$operation->type}") }; } 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 = []; $timelineParents = []; $timelineStatus = []; $timelineSharedFields = []; 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, 'processed_fields' => array_keys($postData) ]; $progress->advance(); continue; } // Existing post update if (!$this->verifyOwnership((int)$id)) { $progress->failItem($id, 'No permission to modify this post'); $errors[$id] = 'No permission'; continue; } $this->savePostFields((int)$id, $postData); if (Features::forContent($content)->has('is_timeline')) { $post = get_post((int)$id); $parentId = $post->post_parent > 0 ? $post->post_parent : $post->ID; $sharedFields = array_keys(array_filter(JVB_CONTENT[$content]['fields'], function ($field) { return !array_key_exists('for_all', $field) || !$field['for_all']; })); if (array_key_exists('post_date', $postData) && !in_array($parentId, $timelineParents)) { $timelineParents[] = $parentId; } if ($parentId === $id) { if (array_key_exists('post_status', $postData) && !array_key_exists($parentId, $timelineStatus)) { $timelineStatus[$parentId] = $postData['post_status']; } if (count(array_intersect($sharedFields, array_keys($postData))) > 0) { if (!array_key_exists($parentId, $timelineSharedFields)) { $timelineSharedFields[$parentId] = []; } $temp = array_intersect($sharedFields, array_keys($postData)); $timelineSharedFields[$parentId] = array_unique(array_merge($timelineSharedFields[$parentId], $temp)); } } } $results[$id] = [ 'success' => true, 'processed_fields' => array_keys($postData) ]; $progress->advance(); } catch (Exception $e) { $progress->failItem($id, $e->getMessage()); $errors[$id] = $e->getMessage(); $results[$id] = [ 'success' => false, 'error' => $e->getMessage() ]; } } try { if (!empty($timelineSharedFields)) { $this->checkSharedFields($timelineSharedFields); } if (!empty($timelineStatus)) { $this->handleTimelineStatusChange($timelineStatus); } if (!empty($timelineParents)) { $this->maybeReorderTimelines($timelineParents); } } catch (Exception $e) { $errors[] = $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: [ 'posts' => $results, 'errors' => $errors, 'updated_count' => count(array_filter($results, fn($r) => $r['success'] ?? false)), 'failed_count' => count($errors) ] ); } 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); } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── private function verifyOwnership(int $postId): bool { $post = get_post($postId); return $post && (int)$post->post_author === $this->userId; } /************************************************************* * TIMELINE HELPERS *************************************************************/ protected function maybeReorderTimelines(array $parentIDs):void { foreach ($parentIDs as $parentId) { try { $this->maybeReorderTimeline((int)$parentId); } catch (Exception $e) { error_log("Timeline reorder failed for parent {$parentId}: " . $e->getMessage()); } } } protected function maybeReorderTimeline(int $parentID):void { // clean_post_cache($parentID); $parent = get_post($parentID); if (!$parent) { return; } $children = get_children([ 'post_parent' => $parentID, 'posts_per_page' => -1, 'post_status' => ['publish', 'draft'], ]); if (count($children) === 0) { return; } $allPosts = array_merge([$parent], $children); // Sort by post_date usort($allPosts, function($a, $b) { return strtotime($a->post_date) <=> strtotime($b->post_date); }); // Check if order changed $needsReorder = false; foreach ($allPosts as $index => $post) { if ($index === 0 && $post->ID !== $parent->ID) { $needsReorder = true; break; } if ($index > 0 && (int)$post->menu_order !== $index) { $needsReorder = true; break; } } if (!$needsReorder) { // Just recalculate timelines without reordering $this->recalculateTimelines($allPosts); return; } // Handle parent swap if needed $newParent = $allPosts[0]; if ($newParent->ID !== $parent->ID) { $this->swapTimelineParent($parent, $newParent, $allPosts); } else { // Just update menu orders and timelines foreach ($allPosts as $index => $post) { if ($index === 0) continue; // Skip parent $success = jvb_update_post([ 'ID' => $post->ID, 'post_parent' => $newParent->ID, 'menu_order' => $index ]); } $this->recalculateTimelines($allPosts); } } private function recalculateTimelines(array $posts): void { $previousPost = null; $latestTimestamp = 0; $lastKey = array_key_last($posts); foreach ($posts as $index => $post) { $meta = new MetaManager($post->ID, 'post'); if ($index === 0) { $meta->updateValue('timeline', '', false); $previousPost = $post; continue; // Parent has no timeline } // Calculate timeline from previous post if ($previousPost) { $timeline = $this->calculateTimeline($previousPost, $post); if ($timeline) { $termId = $this->getOrCreateTerm($timeline, 'timeline'); if ($termId) { $success = $meta->updateValue('timeline', $termId, false); } } } if ($lastKey === $index) { $latestTimestamp = strtotime($post->post_date); } $previousPost = $post; } // Update parent's latest_date if ($latestTimestamp > 0) { $success = update_post_meta($posts[0]->ID, BASE . 'latest_date', $latestTimestamp); } } private function calculateTimeline(\WP_POST $previous, \WP_POST $current): ?string { $previousDate = strtotime($previous->post_date); $currentDate = strtotime($current->post_date); if (!$previousDate || !$currentDate || $currentDate <= $previousDate) { return null; } $daysDiff = floor(($currentDate - $previousDate) / (60*60*24)); $weeks = floor($daysDiff / 7); if ($weeks === 0) { return 'Less than 1 Week'; } if ($weeks < 16) { return $weeks === 1 ? '1 Week' : $weeks . ' Weeks'; } $previousDateTime = new \DateTime($previous->post_date); $currentDateTime = new \DateTime($current->post_date); $interval = $previousDateTime->diff($currentDateTime); $months = ($interval->y * 12) + $interval->m; if ($months === 0) { return $weeks . ' Weeks'; } return ($months === 1) ? '1 Month' : $months . ' Months'; } private function swapTimelineParent(\WP_Post $oldParent, \WP_Post $newParent, array $allPosts): void { // Swap titles and content $originalTitle = $oldParent->post_title; $originalSlug = $oldParent->post_name; $originalContent = $oldParent->post_content; $updateParent = jvb_update_post([ 'ID' => $oldParent->ID, 'post_title' => 'Treatment', 'post_name' => sanitize_title('Treatment ' . $newParent->ID), 'post_content' => '', ]); $updateNewParent = jvb_update_post([ 'ID' => $newParent->ID, 'post_title' => $originalTitle, 'post_name' => $originalSlug, 'post_content' => $originalContent, 'post_parent' => 0, 'menu_order' => 0 ]); // Clear timeline taxonomy from new parent wp_set_object_terms($newParent->ID, [], BASE . 'timeline', false); // Update all other posts to new parent foreach ($allPosts as $index => $post) { if ($index === 0) continue; // Skip new parent $title = $post->post_title; if (str_starts_with($title, 'Treatment #')) { $title = 'Treatment #' . $index; } $childUpdate = jvb_update_post([ 'ID' => $post->ID, 'post_title' => $title, 'post_parent' => $newParent->ID, 'menu_order' => $index, ]); } // Recalculate timelines for all posts $this->recalculateTimelines($allPosts); } private function getOrCreateTerm(string $termName, string $taxonomy): ?int { $taxonomy = jvbCheckBase($taxonomy); $term = get_term_by('name', $termName, $taxonomy); if (!$term) { $result = wp_insert_term($termName, $taxonomy); if (is_wp_error($result)) { return null; } return $result['term_id']; } return $term->term_id; } protected function checkSharedFields(array $fields): void { foreach ($fields as $parentID => $shared) { $meta = new MetaManager($parentID, 'post'); $values = $meta->getAll($shared); $children = get_children([ 'post_parent' => $parentID, 'posts_per_page' => -1, 'fields' => 'ids', ]); if (empty($children)) { continue; } foreach ($children as $child) { $childMeta = new MetaManager($child, 'post'); $result = $childMeta->setAll($values, false); } } } protected function handleTimelineStatusChange(array $updates):void { $updates = array_filter($updates, function ($status) { return in_array($status, ['trash', 'delete', 'publish', 'draft']); }); foreach ($updates as $parentID => $status) { $children = get_children([ 'post_parent' => $parentID, 'posts_per_page' => -1, 'fields' => 'ids' ]); if (!empty($children)) { foreach($children as $child) { if ($status === 'trash') { wp_trash_post($child); } elseif ($status === 'delete') { wp_delete_post($child, true); }else { jvb_update_post([ 'ID' => $child, 'post_status' => $status ]); } } } } } }