PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
extendify / app / Agent / Admin.php

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

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