PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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 0.7.0 All 126 releases
extendify / app / Agent / Admin.php

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

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