PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / Agent / Admin.php

Admin.php in Extendify 3.2.1, at app/Agent/Admin.php

586 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Admin.
5 */
6
7 namespace Extendify\Agent;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Agent\Controllers\ChatHistoryController;
12 use Extendify\Agent\Controllers\TourController;
13 use Extendify\Config;
14 use Extendify\Constants;
15 use Extendify\Shared\Services\Escaper;
16 use Extendify\Shared\Services\HttpClient;
17 use Extendify\Agent\TagBlocks;
18 use Extendify\Agent\TagTemplateParts;
19 use Extendify\Agent\WooProductImages;
20 use Extendify\Agent\AbilitiesDiscovery;
21 use Extendify\Agent\Controllers\SiteNavigationController;
22 use Extendify\PartnerData;
23 use Extendify\Shared\DataProvider\ProductsData;
24 use Extendify\SiteVisibility;
25
26 /**
27 * This class handles any file loading for the admin area.
28 */
29 class Admin
30 {
31 /**
32 * Adds various actions to set up the page
33 *
34 * @return void
35 */
36 public function __construct()
37 {
38 \add_action('admin_enqueue_scripts', [$this, 'loadScriptsAndStyles']);
39 \add_action('wp_enqueue_scripts', [$this, 'loadScriptsAndStyles']);
40 ChatHistoryController::init();
41
42 // Tag blocks so we can identify them later
43 TagBlocks::init();
44 TagTemplateParts::init();
45
46 // Add the site navigation ids to the navigation blocks
47 SiteNavigationController::init();
48
49 Skeleton::init();
50
51 WooProductImages::init();
52
53 \add_action('extendify_agent_suggestions_refresh', [$this, 'refreshSuggestions']);
54 }
55
56 /**
57 * Where the agent panel mounts — docked-left only on the onboarding front end.
58 *
59 * @return string
60 */
61 public static function agentPosition()
62 {
63 $agentOnboarding = PartnerData::setting('useAgentOnboarding') ||
64 Config::preview('agent-onboarding') ||
65 constant('EXTENDIFY_DEVMODE');
66
67 return $agentOnboarding && !is_admin() ? 'docked-left' : 'floating';
68 }
69
70 /**
71 * Adds various JS scripts and styles
72 *
73 * @return void
74 */
75 public function loadScriptsAndStyles()
76 {
77 // The Customizer preview iframe is a front-end render, so this fires
78 // there too — but the Agent only belongs on the live, top-level page.
79 if (is_customize_preview()) {
80 return;
81 }
82
83 $version = constant('EXTENDIFY_DEVMODE') ? uniqid() : Config::$version;
84 $scriptAssetPath = EXTENDIFY_PATH . 'public/build/' . Config::$assetManifest['extendify-agent.php'];
85 $fallback = [
86 'dependencies' => [],
87 'version' => $version,
88 ];
89 $scriptAsset = file_exists($scriptAssetPath) ? require $scriptAssetPath : $fallback;
90
91 foreach ($scriptAsset['dependencies'] as $style) {
92 \wp_enqueue_style($style);
93 }
94
95 \wp_enqueue_script(
96 Config::$slug . '-agent-scripts',
97 EXTENDIFY_BASE_URL . 'public/build/' . Config::$assetManifest['extendify-agent.js'],
98 array_merge([Config::$slug . '-shared-scripts'], $scriptAsset['dependencies']),
99 $scriptAsset['version'],
100 true
101 );
102
103 $context = [
104 'adminPage' => function_exists('get_current_screen') && ($screen = get_current_screen())
105 ? \esc_attr($screen->id)
106 : null,
107 'postId' => (int) $this->getCurrentPostId(),
108 'postTitle' => \esc_attr(\get_the_title($this->getCurrentPostId())),
109 'postType' => \esc_attr(\get_post_type($this->getCurrentPostId())),
110 'postUrl' => \esc_url(\get_permalink($this->getCurrentPostId())),
111 'isFrontPage' => (bool) \is_front_page(),
112 'postStatus' => \esc_attr(\get_post_status((int) $this->getCurrentPostId())),
113 'isBlogPage' => (bool) \is_home(),
114 'themeSlug' => \esc_attr(\wp_get_theme()->get_stylesheet()),
115 'hasThemeVariations' => (bool) $this->hasThemeVariations(),
116 'isBlockTheme' => function_exists('wp_is_block_theme') ? (bool) wp_is_block_theme() : false,
117 'wordPressVersion' => \esc_attr(\get_bloginfo('version')),
118 'usingBlockEditor' => function_exists('use_block_editor_for_post') ?
119 (bool) use_block_editor_for_post($this->getCurrentPostId()) :
120 false,
121 'isOnEditorOrFSE' => $this->isGutenbergOrFse(),
122 'activePlugins' => array_values(\get_option('active_plugins', [])),
123 'isUsingVibes' => version_compare((string) wp_get_theme('extendable')->get('Version'), '2.0.32', '>='),
124 'siteTitle' => \esc_attr(\get_bloginfo('name')),
125 'siteDescription' => \esc_attr(\get_bloginfo('description')),
126 'themePresets' => $this->getThemePresets(),
127 'presetSlugs' => $this->getPresetSlugs(),
128 ];
129 $recommendations = ProductsData::get() ?? [];
130 $pluginRecommendations = array_filter($recommendations, function ($item) {
131 return in_array('ai-agent', $item['slots'] ?? [], true) && $item['ctaType'] === 'plugin';
132 });
133 $mappedPluginRecommendations = array_values(array_map(function ($item) {
134 return [
135 'title' => $item['title'] ?? '',
136 'slug' => $item['ctaPluginSlug'] ?? $item['slug'] ?? '',
137 'description' => $item['aiDescription'] ?? $item['description'] ?? '',
138 'redirectTo' => $item['pluginSetupUrl'] ?? '', // this is a partial setup URL not full one.
139 ];
140 }, $pluginRecommendations));
141 $agentContext = [
142 'availableAdminPages' => get_option('_transient_extendify_admin_pages_menu', []),
143 'pluginRecommendations' => $mappedPluginRecommendations,
144 'sitePublished' => SiteVisibility::isPublished(),
145 'comingSoonEnabled' => (bool) PartnerData::setting('useComingSoon'),
146 ];
147 $abilities = [
148 'canEditPost' => (bool) \current_user_can('edit_post', \get_queried_object_id()),
149 // TODO: this may be true for a user, while they still can't edit every post
150 // So we would need to clarify this in the instructions, and
151 // include a step that fetches the page they want to edit
152 'canEditPosts' => (bool) \current_user_can('edit_posts'),
153 'canEditThemes' => (bool) \current_user_can('edit_theme_options'),
154 'canActivatePlugins' => (bool) \current_user_can('activate_plugins'),
155 'canInstallPlugins' => (bool) \current_user_can('install_plugins'),
156 'canEditUsers' => (bool) \current_user_can('edit_users'),
157 'canEditSettings' => (bool) \current_user_can('manage_options'),
158 'canUploadMedia' => (bool) \current_user_can('upload_files'),
159 ];
160
161 $agentOnboarding = PartnerData::setting('useAgentOnboarding') ||
162 Config::preview('agent-onboarding') ||
163 constant('EXTENDIFY_DEVMODE');
164
165 \wp_add_inline_script(
166 Config::$slug . '-agent-scripts',
167 'window.extAgentData = ' . \wp_json_encode([
168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
169 'startOnboarding' => isset($_GET['extendify-launch-success']) && $agentOnboarding,
170 'agentPosition' => self::agentPosition(),
171 // Add context about where they are
172 'context' => $context,
173 // Material a workflow may want; each declares the keys it reads.
174 'agentContext' => $agentContext,
175 // List of abilities the AI can perform for this user.
176 // For example, we could check whether their theme has variations.
177 'abilities' => $abilities,
178 // Registered WordPress Abilities (6.9+) this user may run.
179 'wpAbilities' => AbilitiesDiscovery::discover(),
180 // List of suggestions the AI can make for this user.
181 // For example, we could check whether they need to set up a specific plugin.
182 'suggestions' => $this->getSuggestions(),
183 'domainsSuggestionSettings' => [
184 'showPrimary' => (bool) PartnerData::setting('showPrimaryDomainRecommendationAgent'),
185 'showSecondary' => (bool) PartnerData::setting('showSecondaryDomainRecommendationAgent'),
186 'stagingSites' => PartnerData::setting('stagingSites'),
187 'searchUrl' => PartnerData::setting('domainSearchURL'),
188 ],
189 'chatHistory' => ChatHistoryController::getChatHistory(),
190 'userData' => [
191 'tourData' => \wp_json_encode(TourController::get()->get_data()),
192 'domainsRecommendationsActivities' => \wp_json_encode(
193 \get_option('extendify_domains_recommendations_activities', null)
194 ),
195 ],
196 ]),
197 'before'
198 );
199
200 \wp_set_script_translations(
201 Config::$slug . '-agent-scripts',
202 'extendify-local',
203 EXTENDIFY_PATH . 'languages/js'
204 );
205
206 \wp_enqueue_style(
207 Config::$slug . '-agent-styles',
208 EXTENDIFY_BASE_URL . 'public/build/' . Config::$assetManifest['extendify-agent.css'],
209 [],
210 Config::$version,
211 'all'
212 );
213 }
214 /**
215 * Get the current post ID based on the context.
216 *
217 * @return int
218 */
219 private function getCurrentPostId()
220 {
221 if (is_admin() && function_exists('get_current_screen')) {
222 $screen = get_current_screen();
223 if ($screen && $screen->base === 'post') {
224 global $post;
225 if ($post) {
226 return (int) $post->ID;
227 }
228 }
229 }
230 if (\is_front_page()) {
231 return (\get_option('show_on_front') === 'page') ? (int) \get_option('page_on_front') : 0;
232 }
233 if (\is_home()) {
234 return (int) \get_option('page_for_posts');
235 }
236 return (int) \get_queried_object_id();
237 }
238
239 /**
240 * Get theme presets (colors, fonts, etc.) as slug => value maps.
241 *
242 * @return array
243 */
244 private function getThemePresets()
245 {
246 if (!function_exists('wp_get_global_settings')) {
247 return ['colors' => [], 'fontSizes' => [], 'fontFamilies' => [], 'duotone' => []];
248 }
249
250 $settings = \wp_get_global_settings();
251
252 $colors = [];
253 $colorPalette = $settings['color']['palette']['theme'] ?? [];
254 foreach ($colorPalette as $item) {
255 if (isset($item['slug'], $item['color'])) {
256 $colors[$item['slug']] = $item['color'];
257 }
258 }
259
260 $duotone = [];
261 $duotonePresets = $settings['color']['duotone']['theme'] ?? [];
262 foreach ($duotonePresets as $item) {
263 if (isset($item['slug'], $item['colors']) && is_array($item['colors'])) {
264 $duotone[] = [
265 'slug' => $item['slug'],
266 'colors' => $item['colors'],
267 ];
268 }
269 }
270
271 $fontSizes = [];
272 $fontSizePresets = $settings['typography']['fontSizes']['theme'] ?? [];
273 foreach ($fontSizePresets as $item) {
274 if (isset($item['slug'], $item['size'])) {
275 $fontSizes[$item['slug']] = $item['size'];
276 }
277 }
278
279 $fontFamilies = [];
280 $fontFamilyPresets = $settings['typography']['fontFamilies']['theme'] ?? [];
281 foreach ($fontFamilyPresets as $item) {
282 if (isset($item['slug'], $item['fontFamily'])) {
283 $fontFamilies[$item['slug']] = $item['fontFamily'];
284 }
285 }
286
287 $colorPairs = [];
288 if (function_exists('wp_get_global_styles')) {
289 $colorPairs = self::extractColorPairs();
290 }
291
292 return [
293 'colors' => $colors,
294 'duotone' => $duotone,
295 'fontSizes' => $fontSizes,
296 'fontFamilies' => $fontFamilies,
297 'colorPairs' => $colorPairs,
298 ];
299 }
300
301 /**
302 * Preset slugs per design-token family across every origin. Feeds the Agent's
303 * block-attribute schema so the model picks named tokens, not raw CSS.
304 *
305 * @return array<string,string[]>
306 */
307 private function getPresetSlugs()
308 {
309 if (!function_exists('wp_get_global_settings')) {
310 return [];
311 }
312
313 $color = \wp_get_global_settings(['color']);
314 $typography = \wp_get_global_settings(['typography']);
315
316 return [
317 'color' => $this->originSlugs($color, 'palette', 'defaultPalette'),
318 'colorValues' => $this->originValues($color, 'palette', 'defaultPalette', 'color'),
319 'gradient' => $this->originSlugs($color, 'gradients', 'defaultGradients'),
320 'fontSize' => $this->originSlugs($typography, 'fontSizes', 'defaultFontSizes'),
321 'fontFamily' => $this->originSlugs($typography, 'fontFamilies', 'defaultFontFamilies'),
322 ];
323 }
324
325 /**
326 * Slug => value, so a colour written as its own hex finds the token naming it.
327 *
328 * @return array<string, string>
329 */
330 private function originValues($node, $listKey, $defaultKey, $valueKey)
331 {
332 $list = $node[$listKey] ?? [];
333
334 $origins = ['custom', 'theme'];
335 if (($node[$defaultKey] ?? true) !== false) {
336 $origins[] = 'default';
337 }
338
339 $values = [];
340 foreach ($origins as $origin) {
341 foreach ($list[$origin] ?? [] as $item) {
342 if (isset($item['slug'], $item[$valueKey])) {
343 $values[(string) $item['slug']] = (string) $item[$valueKey];
344 }
345 }
346 }
347
348 return $values;
349 }
350
351 /**
352 * Preset slugs from a settings node's origin buckets; drops the `default`
353 * origin when the theme opted the family out.
354 *
355 * @return string[]
356 */
357 private function originSlugs($node, $listKey, $defaultKey)
358 {
359 $list = $node[$listKey] ?? [];
360
361 $origins = ['custom', 'theme'];
362 if (($node[$defaultKey] ?? true) !== false) {
363 $origins[] = 'default';
364 }
365
366 $slugs = [];
367 foreach ($origins as $origin) {
368 foreach ($list[$origin] ?? [] as $item) {
369 if (isset($item['slug'])) {
370 $slugs[] = (string) $item['slug'];
371 }
372 }
373 }
374
375 return array_values(array_unique($slugs));
376 }
377
378 private static function extractColorSlug(string $value)
379 {
380 if (preg_match('/var\(--wp--preset--color--([^)]+)\)/', $value, $m)) {
381 return $m[1];
382 }
383 return null;
384 }
385
386 private static function extractColorPairs()
387 {
388 $styles = \wp_get_global_styles();
389 $settings = \wp_get_global_settings();
390 $custom = $settings['custom'] ?? [];
391
392 $pairs = [];
393 $seen = [];
394
395 $bodyTextSlug = self::extractColorSlug($styles['color']['text'] ?? '');
396 $bodyBgSlug = self::extractColorSlug($styles['color']['background'] ?? '');
397
398 $candidates = [];
399
400 if ($bodyTextSlug && $bodyBgSlug) {
401 $candidates[] = ['text' => $bodyTextSlug, 'bg' => $bodyBgSlug];
402 }
403
404 $btnTextSlug = self::extractColorSlug($styles['elements']['button']['color']['text'] ?? '')
405 ?? self::extractColorSlug($custom['elements']['button']['color']['text'] ?? '');
406 $btnBgSlug = self::extractColorSlug($styles['elements']['button']['color']['background'] ?? '')
407 ?? self::extractColorSlug($custom['elements']['button']['color']['background'] ?? '');
408 if ($btnTextSlug && $btnBgSlug) {
409 $candidates[] = ['text' => $btnTextSlug, 'bg' => $btnBgSlug];
410 }
411
412 $btnHoverTextSlug = self::extractColorSlug($styles['elements']['button'][':hover']['color']['text'] ?? '')
413 ?? self::extractColorSlug($custom['elements']['button'][':hover']['color']['text'] ?? '');
414 $btnHoverBgSlug = self::extractColorSlug($styles['elements']['button'][':hover']['color']['background'] ?? '')
415 ?? self::extractColorSlug($custom['elements']['button'][':hover']['color']['background'] ?? '');
416 if ($btnHoverTextSlug && $btnHoverBgSlug) {
417 $candidates[] = ['text' => $btnHoverTextSlug, 'bg' => $btnHoverBgSlug];
418 }
419
420 $linkTextSlug = self::extractColorSlug($styles['elements']['link']['color']['text'] ?? '')
421 ?? self::extractColorSlug($custom['elements']['link']['color']['text'] ?? '');
422 if ($linkTextSlug && $bodyBgSlug) {
423 $candidates[] = ['text' => $linkTextSlug, 'bg' => $bodyBgSlug];
424 }
425
426 $headingTextSlug = self::extractColorSlug($styles['elements']['heading']['color']['text'] ?? '')
427 ?? self::extractColorSlug($custom['elements']['heading']['color']['text'] ?? '');
428 if ($headingTextSlug && $bodyBgSlug) {
429 $candidates[] = ['text' => $headingTextSlug, 'bg' => $bodyBgSlug];
430 }
431
432 if ($bodyTextSlug) {
433 $candidates[] = ['text' => $bodyTextSlug, 'bg' => 'tertiary'];
434 }
435 if ($headingTextSlug && $headingTextSlug !== $bodyTextSlug) {
436 $candidates[] = ['text' => $headingTextSlug, 'bg' => 'tertiary'];
437 }
438
439 foreach ($candidates as $pair) {
440 $key = $pair['text'] . '|' . $pair['bg'];
441 if (isset($seen[$key])) {
442 continue;
443 }
444 $seen[$key] = true;
445 $pairs[] = $pair;
446 }
447
448 return $pairs;
449 }
450
451 /**
452 * Scan the style dirs to locate if they have variations.
453 * Ported from here:
454 * https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/class-wp-theme-json-resolver.php#L810
455 *
456 * @return bool
457 */
458 private function hasThemeVariations()
459 {
460 $base_directory = get_stylesheet_directory() . '/styles';
461 $template_directory = get_template_directory() . '/styles';
462
463 if (is_dir($base_directory) && glob($base_directory . '/*.json', GLOB_NOSORT)) {
464 return true;
465 }
466
467 // Only check parent if it's different from child
468 if (
469 $template_directory !== $base_directory &&
470 is_dir($template_directory) &&
471 glob($template_directory . '/*.json', GLOB_NOSORT)
472 ) {
473 return true;
474 }
475
476 return false;
477 }
478
479 /**
480 * Get suggestions for the user.
481 *
482 * @return array
483 */
484 private function getSuggestions()
485 {
486 $locale = \get_locale();
487 $cached = \get_option('extendify_agent_suggestions_' . $locale);
488
489 if (!is_array($cached) || !isset($cached['fetchedAt'])) {
490 return $this->refreshSuggestions($locale) ?? [];
491 }
492
493 $age = time() - $cached['fetchedAt'];
494 if ($age > DAY_IN_SECONDS) {
495 if (!\wp_next_scheduled('extendify_agent_suggestions_refresh', [$locale])) {
496 \wp_schedule_single_event(time(), 'extendify_agent_suggestions_refresh', [$locale]);
497 if (\is_admin()) {
498 \spawn_cron();
499 }
500 }
501 }
502
503 return $cached['data'] ?? [];
504 }
505
506 /**
507 * Fetch suggestions from the API and persist them.
508 * Called synchronously on cold start and via wp-cron when cache is stale.
509 *
510 * @param string $locale - Locale to fetch (cron may run in a different site locale).
511 * @return array|null
512 */
513 public function refreshSuggestions($locale)
514 {
515 // When the refresh runs via wp-cron, the active locale may differ from the cached entry's locale.
516 // Switch so HttpClient sends the matching wp_language and the response lands in the right cache key.
517 $needSwitch = $locale !== \get_locale();
518 if ($needSwitch) {
519 \switch_to_locale($locale);
520 }
521
522 $response = HttpClient::post(
523 Constants::AI_HOST . '/api/agent/suggestions',
524 [],
525 null,
526 true
527 );
528
529 if ($needSwitch) {
530 \restore_previous_locale();
531 }
532
533 $optionKey = 'extendify_agent_suggestions_' . $locale;
534
535 if ($response['code'] !== 200) {
536 // Back off: stamp the cache as fetched ~23h ago so we retry in ~1h instead of every request.
537 $cached = \get_option($optionKey);
538 \update_option(
539 $optionKey,
540 [
541 'data' => is_array($cached) ? ($cached['data'] ?? []) : [],
542 'fetchedAt' => time() - (DAY_IN_SECONDS - HOUR_IN_SECONDS),
543 ],
544 false
545 );
546 return null;
547 }
548
549 $suggestions = $response['response']['suggestions'] ?? [];
550 \update_option(
551 $optionKey,
552 ['data' => $suggestions, 'fetchedAt' => time()],
553 false
554 );
555 return $suggestions;
556 }
557
558 /**
559 * Check if the user is in the Gutenberg or FSE editor.
560 *
561 * @return false|bool
562 */
563 public function isGutenbergOrFse()
564 {
565 if (!is_admin() || !function_exists('get_current_screen')) {
566 return false;
567 }
568
569 $screen = get_current_screen();
570 if (!$screen) {
571 return false;
572 }
573
574 $is_fse =
575 in_array($screen->id, ['site-editor', 'appearance_page_gutenberg-edit-site'], true) ||
576 (isset($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'site-editor.php');
577
578 $is_gutenberg =
579 $screen->base === 'post' &&
580 method_exists($screen, 'is_block_editor') &&
581 $screen->is_block_editor();
582
583 return $is_fse || $is_gutenberg;
584 }
585 }
586