<?php
|
namespace JVBase\managers\queue\executors;
|
|
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
|
use JVBase\meta\MetaManager;
|
use JVBase\utility\Features;
|
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 = [];
|
private array $timelineSharedFields = [];
|
private array $timelineUniqueFields = [];
|
|
public function execute(Operation $operation, Progress $progress): Result
|
{
|
if (!in_array($operation->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,
|
'processed_fields' => array_keys($postData)
|
];
|
|
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');
|
$errors[$id] = 'No permission';
|
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,
|
'processed_fields' => array_keys($postData)
|
];
|
$progress->advance(1);
|
|
} catch (Exception $e) {
|
$progress->failItem($id, $e->getMessage());
|
$errors[$id] = $e->getMessage();
|
$results[$id] = [
|
'success' => false,
|
'error' => $e->getMessage()
|
];
|
}
|
}
|
if (!empty($updateTimelineOrder)) {
|
$processedParents = []; // Track to avoid duplicate processing
|
|
foreach ($updateTimelineOrder as $oldParentID) {
|
if (in_array($oldParentID, $processedParents)) continue;
|
|
$actualParentId = $this->reorderTimelineByDate($oldParentID);
|
$processedParents[] = $actualParentId;
|
|
// If parent changed, mark the new parent as processed too
|
if ($actualParentId !== $oldParentID) {
|
$processedParents[] = $oldParentID;
|
}
|
}
|
}
|
|
// 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 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): int
|
{
|
$parent = get_post($parentId);
|
if (!$parent) return $parentId;
|
|
// 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];
|
$actualParentId = $newParent->ID; // Track the actual parent
|
|
// 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;
|
}
|
|
// Update latest_date AFTER reordering with the actual parent
|
$this->updateTimelineLatestDate($actualParentId);
|
|
return $actualParentId; // Return the actual parent 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'
|
]);
|
|
// Count: parent + children
|
$number = count($children) + 1;
|
|
$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);
|
|
// Update both meta fields
|
update_post_meta($parentId, BASE . 'number', $number);
|
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);
|
}
|
|
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;
|
}
|
}
|