<?php
|
namespace JVBase\managers\queue\executors;
|
|
use JVBase\managers\queue\{Executor, Operation, Progress, Result, Storage};
|
use JVBase\meta\Meta;
|
use JVBase\registrar\Registrar;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Executor for content-related queue operations.
|
* Handles: content_update, batch_creation
|
*/
|
final class ContentExecutor implements Executor
|
{
|
private const HANDLED_TYPES = [
|
'content_update',
|
// 'batch_creation',
|
];
|
|
private int $userId;
|
private string $postType;
|
private array $fields = [];
|
|
public function execute(Operation $operation, Progress $progress): Result
|
{
|
$this->userId = $operation->userId;
|
|
if (!in_array($operation->type, self::HANDLED_TYPES)) {
|
throw new Exception("ContentExecutor cannot handle type: {$operation->type}");
|
}
|
error_log('Executing ContentExecutor.php');
|
|
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'] ?? [];
|
error_log('Processing Content Update: '.print_r($posts, true));
|
if (empty($posts)) {
|
return new Result(
|
outcome: 'success',
|
result: ['message' => 'No posts to update']
|
);
|
}
|
|
$results = [
|
'errors' => [],
|
'success' => [],
|
'newPosts' => [],
|
'timelineParents' => [],
|
'timelineStatus' => [],
|
'timelineSharedFields' => [],
|
];
|
$errors = [];
|
|
foreach ($posts as $id => $postData) {
|
try {
|
$content = $postData['content'] ?? false;
|
if (!$content) continue;
|
$registrar = Registrar::getInstance($content);
|
switch ($registrar->getType()) {
|
case 'post':
|
$results = $this->handlePost($id, $postData, $registrar, $results, $progress);
|
break;
|
case 'term':
|
$results = $this->handleTerm($id, $postData, $registrar, $results, $progress);
|
break;
|
case 'user':
|
$results = $this->handleUser($id, $postData, $registrar, $results, $progress);
|
break;
|
}
|
} catch (Exception $e) {
|
$progress->failItem($id, $e->getMessage());
|
$results['errors'][$id] = $e->getMessage();
|
}
|
}
|
error_log('Final Results: '.print_r($results, true));
|
|
try {
|
if (!empty($results['timelineSharedFields'])) {
|
$this->checkSharedFields($results['timelineSharedFields']);
|
}
|
if (!empty($results['timelineStatus'])) {
|
$this->handleTimelineStatusChange($results['timelineStatus']);
|
}
|
if (!empty($results['timelineParents'])) {
|
$this->maybeReorderTimelines($results['timelineParents']);
|
}
|
} catch (Exception $e) {
|
$results['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: $results,
|
);
|
}
|
|
private function savePostFields(int $postId, array $postData): bool
|
{
|
$content = $postData['content'] ?? '';
|
$fields = Registrar::getFieldsFor($content);
|
|
$allowedFields = array_filter($postData, function ($key) use ($fields) {
|
return array_key_exists($key, $fields);
|
}, ARRAY_FILTER_USE_KEY);
|
|
//Remove values that are already saved
|
$check = Meta::forPost($postId)->getAll(array_keys($allowedFields));
|
error_log('Stored values: '.print_r($check, true));
|
$allowedFields = array_filter($allowedFields, function ($key) use ($allowedFields, $check) {
|
return $allowedFields[$key] !== $check[$key];
|
}, ARRAY_FILTER_USE_KEY);
|
|
if (empty($allowedFields)) {
|
return true;
|
}
|
|
return Meta::forPost($postId)
|
->setAll($allowedFields);
|
}
|
private function saveTermFields(int $termId, array $data): bool
|
{
|
$content = $data['content'] ?? '';
|
error_log('Saving term fields: '.print_r($data, true));
|
$fields = Registrar::getFieldsFor($content);
|
|
$allowedFields = array_filter($data, function ($key) use ($fields) {
|
return array_key_exists($key, $fields);
|
}, ARRAY_FILTER_USE_KEY);
|
|
|
//Remove values that are already saved
|
$check = Meta::forTerm($termId)->getAll(array_keys($allowedFields));
|
error_log('Stored values: '.print_r($check, true));
|
$allowedFields = array_filter($allowedFields, function ($value, $key) use ($check) {
|
error_log('Sent value: '.print_r($value, true));
|
error_log('Stored Value: '.print_r($check[$key], true));
|
return $value !== $check[$key];
|
}, ARRAY_FILTER_USE_BOTH);
|
|
if (empty($allowedFields)) {
|
return true;
|
}
|
|
error_log('Allowed fields: '.print_r($allowedFields, true));
|
|
return Meta::forTerm($termId)
|
->setAll($allowedFields);
|
}
|
private function saveUserFields(int $userId, array $data): bool
|
{
|
$content = $data['content'] ?? '';
|
$fields = Registrar::getFieldsFor($content);
|
|
$allowedFields = array_filter($data, function ($key) use ($fields) {
|
return array_key_exists($key, $fields);
|
}, ARRAY_FILTER_USE_KEY);
|
|
//Remove values that are already saved
|
$check = Meta::forUser($userId)->getAll(array_keys($allowedFields));
|
$allowedFields = array_filter($allowedFields, function ($key) use ($allowedFields, $check) {
|
return $allowedFields[$key] !== $check[$key];
|
}, ARRAY_FILTER_USE_KEY);
|
|
if (empty($allowedFields)) {
|
return true;
|
}
|
|
return Meta::forUser($userId)
|
->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 getTimelinePosts(int $parentID):array
|
{
|
$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);
|
});
|
|
return $allPosts;
|
}
|
protected function maybeReorderTimeline(int $parentID):void
|
{
|
// clean_post_cache($parentID);
|
$parent = get_post($parentID);
|
if (!$parent) {
|
return;
|
}
|
|
$allPosts = $this->getTimelinePosts($parentID);
|
if (empty($allPosts)) {
|
return;
|
}
|
|
|
|
// 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 = Meta::forPost($post->ID);
|
if ($index === 0) {
|
$meta->set('timeline', '');
|
$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->set('timeline', $termId);
|
}
|
}
|
}
|
$isUpdate = $meta->get('is_update');
|
if (!(bool) $isUpdate) {
|
$meta->set('number', $index);
|
}
|
|
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 = Meta::forPost($parentID);
|
$values = $meta->getAll($shared);
|
|
$children = get_children([
|
'post_parent' => $parentID,
|
'posts_per_page' => -1,
|
'fields' => 'ids',
|
]);
|
|
if (empty($children)) {
|
continue;
|
}
|
|
foreach ($children as $child) {
|
Meta::forPost($child)->setAll($values);
|
}
|
}
|
}
|
|
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
|
]);
|
}
|
|
}
|
}
|
}
|
}
|
|
protected function handlePost(string|int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
|
{
|
// New post creation
|
if (str_starts_with((string)$ID, 'new')) {
|
|
$newId = wp_insert_post([
|
'post_author' => $this->userId,
|
'post_type' => $registrar->getBased(),
|
'post_title' => $data['post_title'] ?? apply_filters('jvbDefaultTitle', '', $registrar->getSlug()),
|
'post_status' => $data['status'] ?? 'draft',
|
]);
|
error_log('Created new post: '.print_r($newId, true));
|
|
if (!$newId || is_wp_error($newId)) {
|
$results['errors'][$ID] = 'Could not create post';
|
$progress->failItem($ID, 'Could not create post');
|
return $results;
|
}
|
|
$results['newPosts'][$ID] = $newId;
|
$this->savePostFields($newId, $data);
|
unset($data['content']);
|
$results['success'][$newId] = $data;
|
$progress->advance();
|
return $results;
|
}
|
|
//Existing post update
|
if (!$this->verifyOwnership((int)$ID)) {
|
$progress->failItem($ID, 'No permission to modify this post');
|
$results['errors'][$ID] = 'No permission';
|
return $results;
|
}
|
|
$result = $this->savePostFields((int)$ID, $data);
|
unset($data['content']);
|
if ($result) {
|
$results['success'][$ID] = $data;
|
} else {
|
$results['errors'][$ID] = 'Could not update post data';
|
}
|
if ($registrar && $registrar->hasFeature('is_timeline')) {
|
$post = get_post((int)$ID);
|
$parentId = $post->post_parent > 0 ? $post->post_parent : $post->ID;
|
$fields = $registrar->getFields();
|
$sharedFields = array_keys(array_filter($fields, function ($field) {
|
return !array_key_exists('for_all', $field) || !$field['for_all'];
|
}));
|
|
|
if (array_key_exists('timeline_gallery', $data)) {
|
//This should only happen if we delete an image from the gallery
|
$changes = explode(',', $data['timeline_gallery']);
|
$timelinePosts = $this->getTimelinePosts($parentId);
|
if (!empty($timelinePosts)) {
|
$posts = array_map(function($item) { return $item->ID; }, $timelinePosts);
|
$changed = false;
|
foreach ($posts as $tID) {
|
if (!in_array($tID, $changes)) {
|
$changed = true;
|
wp_delete_post($tID, true);
|
}
|
}
|
if ($changed) {
|
$results['timelineParents'][] = $parentId;
|
}
|
}
|
|
}
|
|
|
if (array_key_exists('post_date', $data) && !in_array($parentId, $results['timelineParents'])) {
|
$results['timelineParents'][] = $parentId;
|
}
|
if ($parentId === $ID) {
|
if (array_key_exists('post_status', $data) && !array_key_exists($parentId, $results['timelineStatus'])) {
|
$results['timelineStatus'][$parentId] = $data['post_status'];
|
}
|
|
if (count(array_intersect($sharedFields, array_keys($data))) > 0) {
|
if (!array_key_exists($parentId, $results['timelineSharedFields'])) {
|
$results['timelineSharedFields'][$parentId] = [];
|
}
|
$temp = array_intersect($sharedFields, array_keys($data));
|
$results['timelineSharedFields'][$parentId] = array_unique(array_merge($results['timelineSharedFields'][$parentId], $temp));
|
}
|
}
|
}
|
$progress->advance();
|
return $results;
|
}
|
|
|
protected function handleTerm(int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
|
{
|
error_log('Handling term '.$ID.' with data: '.print_r($data, true));
|
//Existing term update
|
if ($registrar->hasFeature('is_ownable') && (!JVB()->roles()->isOwner($this->userId, $ID) && !JVB()->roles()->isManager($this->userId, $ID))) {
|
error_log('Term is ownable. User does not own this term.');
|
$progress->failItem($ID, 'No permission to modify this term');
|
$results['errors'][$ID] = 'No permission';
|
return $results;
|
}
|
|
$result = $this->saveTermFields($ID, $data);
|
unset($data['content']);
|
if ($result) {
|
$results['success'][$ID] = $data;
|
} else {
|
$results['errors'][$ID] = 'Could not update term data';
|
}
|
$progress->advance();
|
return $results;
|
}
|
protected function handleUser(int $ID, array $data, Registrar $registrar, array $results, Progress $progress):array
|
{
|
//Existing term update
|
if ($ID !== $this->userId || !user_can($this->userId, 'manage_options')) {
|
$progress->failItem($ID, 'No permission to modify this term');
|
$results['errors'][$ID] = 'No permission';
|
return $results;
|
}
|
|
$result = $this->saveUserFields($ID, $data);
|
unset($data['content']);
|
if ($result) {
|
$results['success'][$ID] = $data;
|
} else {
|
$results['errors'][$ID] = 'Could not update post data';
|
}
|
$progress->advance();
|
return $results;
|
}
|
}
|