jobRepository = new ContaiJobRepository(); } public function getStepCount(): int { return count($this->steps); } public function handle(array $payload) { //set_time_limit(300); //ini_set('max_execution_time', '300'); $jobId = $payload['job_id'] ?? null; $currentStep = $payload['progress']['current_step'] ?? 0; $completedSteps = $payload['progress']['completed_steps'] ?? []; $stepName = $this->steps[$currentStep] ?? null; if (!$stepName) { return [ 'success' => true, 'message' => 'Site generation completed successfully' ]; } try { $result = $this->executeStep($stepName, $payload); if (isset($result['_wait_and_retry'])) { $this->updateJobPayloadWithoutAdvancing($jobId, $result, $currentStep, $completedSteps); $this->markJobAsPending($jobId); return [ 'success' => false, 'retry' => true, 'message' => $result['_wait_message'] ?? 'Step not ready, will retry' ]; } $payload = $result; $completedSteps[] = $stepName; $this->updateJobPayload($jobId, $payload, $currentStep, $completedSteps); if ($currentStep + 1 < count($this->steps)) { $this->markJobAsPending($jobId); return [ 'success' => false, 'continue' => true, 'message' => "Step '{$stepName}' completed, continuing to next step" ]; } return [ 'success' => true, 'message' => 'Site generation completed successfully' ]; } catch (Exception $e) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception("Step '{$stepName}' failed: " . $e->getMessage()); } } private function executeStep(string $stepName, array $payload): array { $config = $payload['config'] ?? []; switch ($stepName) { case 'validateCredits': $this->validateCreditsStep(); break; case 'activateLicense': $this->activateLicense(); break; case 'saveSiteConfig': $this->saveSiteConfig($config['site_config']); break; case 'saveLegalInfo': $this->saveLegalInfo($config['legal_info']); break; case 'generateWebsite': $this->generateWebsite(); break; case 'extractKeywords': $this->cleanPreviousDataIfReExecution(); $this->extractKeywords($config['keyword_extraction']); break; case 'enqueuePosts': $batchId = $this->enqueuePosts($config['post_generation']); $payload['batch_id'] = $batchId; break; case 'waitForPosts': $payload = $this->waitForPosts($payload['batch_id'] ?? '', $payload); break; case 'generateComments': $this->runOptionalStep('generateComments', function () use ($config) { $this->generateComments($config['comments'] ?? []); }); break; case 'setupSearchConsole': $this->runOptionalStep('setupSearchConsole', function () { $this->setupSearchConsole(); }); break; case 'setupAdsManager': $this->runOptionalStep('setupAdsManager', function () use ($config) { $this->setupAdsManager($config['adsense']['publisher_id'] ?? ''); }); break; case 'setupNavigation': $this->runOptionalStep('setupNavigation', function () { $this->setupNavigation(); }); update_option('contai_site_generation_completed', true); break; } return $payload; } /** * Run an optional wizard step without aborting site generation. * * Optional used to mean catch(Exception) + contai_log(), and contai_log() * only writes when WP_DEBUG is true (includes/helpers/crypto.php:72-76). * Production installs run with WP_DEBUG off, so a failure of * generateComments, setupSearchConsole, setupAdsManager or setupNavigation * left no trace of any kind: handle() still appended the step to * completed_steps, still returned "Site generation completed successfully" * and still set contai_site_generation_completed to true. A site could * therefore finish the wizard reporting success while having no primary * menu, no categories in the menu and no comments - which is why #48 kept * being reopened with no diagnostic to work from. * * Two things change here. The failure is caught as Throwable: a PHP Error * (a TypeError from a theme/API returning an unexpected shape, say) is not * an Exception, so it escaped the old catch and was rethrown by handle(), * killing the whole run - the exact opposite of "optional". And the * failure is recorded durably instead of only in a debug-gated log. * * @param string $stepName Step whose failure is being contained. * @param callable $step The step body. */ private function runOptionalStep(string $stepName, callable $step): void { try { $step(); } catch (\Throwable $e) { $this->recordStepWarning($stepName, $e->getMessage()); } } /** * Record an optional-step failure where it can actually be found. * * error_log() is called unconditionally on purpose - the same choice * already made for the footer-location diagnostic in * includes/helpers/site-generation.php - because contai_log() is a no-op * without WP_DEBUG. The option gives a durable record that survives the * request and can be read back off a generated site without shell access * to the PHP error log. * * @param string $stepName Step that failed. * @param string $message Failure message. */ private function recordStepWarning(string $stepName, string $message): void { // Delegates to the shared recorder so the nav-menu resolvers in // includes/helpers/ write to the SAME durable store, rather than each // concern growing its own FIFO (#48). contai_record_site_warning( $stepName, $message, "optional site-generation step '{$stepName}' failed" ); } private function validateCreditsStep(): void { require_once __DIR__ . '/../billing/CreditGuard.php'; $creditGuard = new ContaiCreditGuard(); $creditCheck = $creditGuard->validateCredits(); if (!$creditCheck['has_credits']) { throw new Exception( sprintf( 'Insufficient balance (%s %s). Please add credits before generating content.', number_format($creditCheck['balance'], 2), $creditCheck['currency'] ) ); } } private function activateLicense(): void { $service = new ContaiUserProfileService(); if (!$service->hasApiKey()) { throw new Exception('License activation failed: No API key configured'); } $result = $service->refreshUserProfile(); if (!$result['success']) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('License activation failed: ' . $result['message']); } // Ensure website record exists in API — required for theme tracking, // tagline generation, and API sync in subsequent steps (#55) $websiteProvider = new ContaiWebsiteProvider(); $websiteResult = $websiteProvider->ensureWebsiteExists(); if (!$websiteResult['success']) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Website registration failed: ' . ($websiteResult['message'] ?? 'Unknown error')); } } private function saveSiteConfig(array $siteConfig): void { $service = new ContaiSiteConfigService(); $errors = $service->validateSiteConfiguration($siteConfig); if (!empty($errors)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception(implode(', ', $errors)); } $service->saveSiteConfiguration($siteConfig); } private function saveLegalInfo(array $legalInfo): void { $service = new ContaiLegalInfoService(); $errors = $service->validateLegalInfo($legalInfo); if (!empty($errors)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception(implode(', ', $errors)); } $service->saveLegalInfo($legalInfo); } private function generateWebsite(): void { $service = new ContaiWebsiteGenerationService(); $result = $service->generateCompleteWebsite(); if (!$result['success']) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Website generation failed: ' . implode(', ', $result['errors'])); } } private function isReExecution(): bool { return (bool) get_option('contai_site_generation_completed', false); } private function cleanPreviousDataIfReExecution(): void { if (!$this->isReExecution()) { return; } error_log('[ContAI] Re-execution detected — cleaning previous data'); // Clean categories where ALL posts are wizard-generated $categories = get_categories([ 'hide_empty' => false, 'exclude' => [get_option('default_category')], ]); foreach ($categories as $category) { $total_posts = $category->count; if ($total_posts === 0) { wp_delete_term($category->term_id, 'category'); continue; } $contai_posts = get_posts([ 'category' => $category->term_id, 'meta_key' => '_1platform_ai_generated', 'meta_value' => '1', 'numberposts' => -1, 'fields' => 'ids', ]); if (count($contai_posts) === $total_posts) { wp_delete_term($category->term_id, 'category'); } } // Clean orphaned batch options global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'contai\_batch\_%'" ); error_log('[ContAI] Re-execution cleanup completed'); } private function extractKeywords(array $extractionConfig): void { $service = ContaiKeywordExtractorService::create(); $topic = $extractionConfig['source_topic'] ?? ''; $country = $extractionConfig['target_country'] ?? 'us'; $lang = $extractionConfig['target_language'] ?? 'en'; $result = $service->extractByTopicAndSave($topic, $country, $lang); if (!$result->isSuccess()) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Keyword extraction failed: ' . $result->getErrorMessage()); } } private function enqueuePosts(array $postConfig): string { $service = new ContaiPostGenerationSetupService(); $count = $postConfig['num_posts'] ?? 100; $config = [ 'lang' => $postConfig['target_language'] ?? 'en', 'country' => $postConfig['target_country'] ?? 'us', 'image_provider' => $postConfig['image_provider'] ?? 'pexels', ]; $result = $service->enqueuePostGeneration($count, $config); return $result['batch_id']; } private function waitForPosts(string $batchId, array $payload): array { if (empty($batchId)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Batch ID is missing'); } $service = new ContaiPostGenerationSetupService(); $status = $service->getBatchStatus($batchId); if ($status['is_complete']) { // Fail loudly when the wizard asked for more posts than the queue // could enqueue (shortfall). Previously the job marked itself // "done" with a partial load (e.g. 12 of 100), matching #109/#110. if (!empty($status['is_short'])) { contai_log(sprintf( '[site-gen] batch %s short: requested=%d enqueued=%d done=%d failed=%d', $batchId, $status['requested'] ?? 0, $status['total'], $status['completed'], $status['failed'] )); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception(sprintf( 'Only %d of %d requested posts were generated. The keyword extraction step yielded fewer keywords than requested — broaden the source topic or lower the post count, then re-run the wizard.', $status['completed'], $status['requested'] ?? $status['total'] )); } if ($status['failed'] > 0) { contai_log("Site generation batch {$batchId} completed with {$status['failed']} failed posts out of {$status['total']} total."); } unset($payload['wait_start_time']); unset($payload['_wait_and_retry']); unset($payload['_wait_message']); return $payload; } $maxWaitSeconds = 7200; $waitStartTime = $payload['wait_start_time'] ?? time(); $elapsedTime = time() - $waitStartTime; if ($elapsedTime > $maxWaitSeconds) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Timeout waiting for posts to complete after ' . round($elapsedTime / 60) . ' minutes. Progress: ' . $status['finished'] . '/' . $status['total'] . ' finished (' . $status['failed'] . ' failed).'); } $failedInfo = $status['failed'] > 0 ? " ({$status['failed']} failed)" : ''; // Report progress against the requested target so the UI shows // "12/100" instead of "12/12" when the queue ran short. $progressDenominator = max($status['total'], $status['requested'] ?? $status['total']); $payload['wait_start_time'] = $waitStartTime; $payload['_wait_and_retry'] = true; $payload['_wait_message'] = "Waiting for posts: {$status['finished']}/{$progressDenominator} finished{$failedInfo}. Elapsed: " . round($elapsedTime / 60) . " min."; return $payload; } private function generateComments(array $commentsConfig): void { $service = new ContaiCommentsGenerationService(); $numPosts = $commentsConfig['num_posts'] ?? 20; $commentsPerPost = $commentsConfig['comments_per_post'] ?? 1; $service->generateCommentsForRecentPosts($numPosts, $commentsPerPost); } private function setupSearchConsole(): void { $service = new ContaiSearchConsoleSetupService(); $result = $service->activateSearchConsole(); if (!$result['success']) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception('Search Console setup failed: ' . implode(', ', $result['errors'])); } } private function setupAdsManager(string $publisherId): void { $service = new ContaiAdsenseSetupService(); $errors = $service->validatePublisherId($publisherId); if (!empty($errors)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new Exception(implode(', ', $errors)); } $service->setupAdsense($publisherId); } private function setupNavigation(): void { // Do NOT exclude default_category here. The wizard repurposes that very // term: replaceUncategorizedWithFirstCategory() renames it in place into // the first API category and nothing repoints the option, so excluding // it by id silently dropped a real, post-bearing category from the menu // of every generated site (#48). contai_category_is_unused_default() // keeps the original intent - hide a still-empty placeholder - without // that false assumption. $categories = get_categories([ 'hide_empty' => false, ]); $default_category_id = (int) get_option('default_category'); $repurposed_category_id = (int) get_option(ContaiCategoryService::OPTION_REPURPOSED_DEFAULT, 0); // Always build and assign the primary "Main Navigation" menu, even // when no custom categories exist yet (#48). Without a menu assigned // to the theme's primary nav location, WordPress themes fall back to // wp_page_menu(), which lists published PAGES — i.e. the generated // legal pages — producing the reported "main menu shows only legal // pages, no categories" symptom. A Home-only menu still claims the // primary location and suppresses that fallback; category items are // appended whenever categories are present. $category_names = []; foreach ($categories as $cat) { $is_unused_default = contai_category_is_unused_default( (int) $cat->term_id, (int) $cat->count, $default_category_id, $repurposed_category_id ); if ($is_unused_default) { continue; } $category_names[] = sanitize_text_field($cat->name); } $menuManager = new ContaiMainMenuManager(); $menuManager->updateMainMenuWithCategories($category_names); } private function updateJobPayload(int $jobId, array $payload, int $currentStep, array $completedSteps): void { global $wpdb; $job = $this->jobRepository->findById($jobId); if (!$job) { return; } $nextStep = $currentStep + 1; $nextStepName = $this->steps[$nextStep] ?? null; $payload['progress'] = [ 'current_step' => $nextStep, 'current_step_name' => $nextStepName, 'completed_steps' => $completedSteps, 'total_steps' => count($this->steps), 'updated_at' => current_time('mysql') ]; $job->setPayload($payload); $this->jobRepository->update($job); $table = $wpdb->prefix . 'contai_jobs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, ['processed_at' => current_time('mysql')], ['id' => $jobId], ['%s'], ['%d'] ); } private function updateJobPayloadWithoutAdvancing(int $jobId, array $payload, int $currentStep, array $completedSteps): void { global $wpdb; $job = $this->jobRepository->findById($jobId); if (!$job) { return; } $currentStepName = $this->steps[$currentStep] ?? null; $payload['progress'] = [ 'current_step' => $currentStep, 'current_step_name' => $currentStepName, 'completed_steps' => $completedSteps, 'total_steps' => count($this->steps), 'updated_at' => current_time('mysql') ]; $job->setPayload($payload); $this->jobRepository->update($job); $table = $wpdb->prefix . 'contai_jobs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, ['processed_at' => current_time('mysql')], ['id' => $jobId], ['%s'], ['%d'] ); } private function markJobAsPending(int $jobId): void { global $wpdb; $table = $wpdb->prefix . 'contai_jobs'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, ['status' => 'pending'], ['id' => $jobId], ['%s'], ['%d'] ); } public function getType() { return self::TYPE; } }