PluginProbe
Extendify / 3.2.0
Extendify v3.2.0
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.0, at app/Agent/Admin.php

556 lines 20.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
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 '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 ];
144 $abilities = [
145 'canEditPost' => (bool) \current_user_can('edit_post', \get_queried_object_id()),
146 // TODO: this may be true for a user, while they still can't edit every post
147 // So we would need to clarify this in the instructions, and
148 // include a step that fetches the page they want to edit
149 'canEditPosts' => (bool) \current_user_can('edit_posts'),
150 'canEditThemes' => (bool) \current_user_can('edit_theme_options'),
151 'canActivatePlugins' => (bool) \current_user_can('activate_plugins'),
152 'canInstallPlugins' => (bool) \current_user_can('install_plugins'),
153 'canEditUsers' => (bool) \current_user_can('edit_users'),
154 'canEditSettings' => (bool) \current_user_can('manage_options'),
155 'canUploadMedia' => (bool) \current_user_can('upload_files'),
156 ];
157
158 $agentOnboarding = PartnerData::setting('useAgentOnboarding') ||
159 Config::preview('agent-onboarding') ||
160 constant('EXTENDIFY_DEVMODE');
161
162 \wp_add_inline_script(
163 Config::$slug . '-agent-scripts',
164 'window.extAgentData = ' . \wp_json_encode([
165 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
166 'startOnboarding' => isset($_GET['extendify-launch-success']) && $agentOnboarding,
167 'agentPosition' => self::agentPosition(),
168 // Add context about where they are
169 'context' => $context,
170 // Material a workflow may want; each declares the keys it reads.
171 'agentContext' => $agentContext,
172 // List of abilities the AI can perform for this user.
173 // For example, we could check whether their theme has variations.
174 'abilities' => $abilities,
175 // Registered WordPress Abilities (6.9+) this user may run.
176 'wpAbilities' => AbilitiesDiscovery::discover(),
177 // List of suggestions the AI can make for this user.
178 // For example, we could check whether they need to set up a specific plugin.
179 'suggestions' => $this->getSuggestions(),
180 'domainsSuggestionSettings' => [
181 'showPrimary' => (bool) PartnerData::setting('showPrimaryDomainRecommendationAgent'),
182 'showSecondary' => (bool) PartnerData::setting('showSecondaryDomainRecommendationAgent'),
183 'stagingSites' => PartnerData::setting('stagingSites'),
184 'searchUrl' => PartnerData::setting('domainSearchURL'),
185 ],
186 'chatHistory' => ChatHistoryController::getChatHistory(),
187 'userData' => [
188 'tourData' => \wp_json_encode(TourController::get()->get_data()),
189 'domainsRecommendationsActivities' => \wp_json_encode(
190 \get_option('extendify_domains_recommendations_activities', null)
191 ),
192 ],
193 ]),
194 'before'
195 );
196
197 \wp_set_script_translations(
198 Config::$slug . '-agent-scripts',
199 'extendify-local',
200 EXTENDIFY_PATH . 'languages/js'
201 );
202
203 \wp_enqueue_style(
204 Config::$slug . '-agent-styles',
205 EXTENDIFY_BASE_URL . 'public/build/' . Config::$assetManifest['extendify-agent.css'],
206 [],
207 Config::$version,
208 'all'
209 );
210 }
211 /**
212 * Get the current post ID based on the context.
213 *
214 * @return int
215 */
216 private function getCurrentPostId()
217 {
218 if (is_admin() && function_exists('get_current_screen')) {
219 $screen = get_current_screen();
220 if ($screen && $screen->base === 'post') {
221 global $post;
222 if ($post) {
223 return (int) $post->ID;
224 }
225 }
226 }
227 if (\is_front_page()) {
228 return (\get_option('show_on_front') === 'page') ? (int) \get_option('page_on_front') : 0;
229 }
230 if (\is_home()) {
231 return (int) \get_option('page_for_posts');
232 }
233 return (int) \get_queried_object_id();
234 }
235
236 /**
237 * Get theme presets (colors, fonts, etc.) as slug => value maps.
238 *
239 * @return array
240 */
241 private function getThemePresets()
242 {
243 if (!function_exists('wp_get_global_settings')) {
244 return ['colors' => [], 'fontSizes' => [], 'fontFamilies' => [], 'duotone' => []];
245 }
246
247 $settings = \wp_get_global_settings();
248
249 $colors = [];
250 $colorPalette = $settings['color']['palette']['theme'] ?? [];
251 foreach ($colorPalette as $item) {
252 if (isset($item['slug'], $item['color'])) {
253 $colors[$item['slug']] = $item['color'];
254 }
255 }
256
257 $duotone = [];
258 $duotonePresets = $settings['color']['duotone']['theme'] ?? [];
259 foreach ($duotonePresets as $item) {
260 if (isset($item['slug'], $item['colors']) && is_array($item['colors'])) {
261 $duotone[] = [
262 'slug' => $item['slug'],
263 'colors' => $item['colors'],
264 ];
265 }
266 }
267
268 $fontSizes = [];
269 $fontSizePresets = $settings['typography']['fontSizes']['theme'] ?? [];
270 foreach ($fontSizePresets as $item) {
271 if (isset($item['slug'], $item['size'])) {
272 $fontSizes[$item['slug']] = $item['size'];
273 }
274 }
275
276 $fontFamilies = [];
277 $fontFamilyPresets = $settings['typography']['fontFamilies']['theme'] ?? [];
278 foreach ($fontFamilyPresets as $item) {
279 if (isset($item['slug'], $item['fontFamily'])) {
280 $fontFamilies[$item['slug']] = $item['fontFamily'];
281 }
282 }
283
284 $colorPairs = [];
285 if (function_exists('wp_get_global_styles')) {
286 $colorPairs = self::extractColorPairs();
287 }
288
289 return [
290 'colors' => $colors,
291 'duotone' => $duotone,
292 'fontSizes' => $fontSizes,
293 'fontFamilies' => $fontFamilies,
294 'colorPairs' => $colorPairs,
295 ];
296 }
297
298 /**
299 * Preset slugs per design-token family across every origin. Feeds the Agent's
300 * block-attribute schema so the model picks named tokens, not raw CSS.
301 *
302 * @return array<string,string[]>
303 */
304 private function getPresetSlugs()
305 {
306 if (!function_exists('wp_get_global_settings')) {
307 return [];
308 }
309
310 $color = \wp_get_global_settings(['color']);
311 $typography = \wp_get_global_settings(['typography']);
312
313 return [
314 'color' => $this->originSlugs($color, 'palette', 'defaultPalette'),
315 'gradient' => $this->originSlugs($color, 'gradients', 'defaultGradients'),
316 'fontSize' => $this->originSlugs($typography, 'fontSizes', 'defaultFontSizes'),
317 'fontFamily' => $this->originSlugs($typography, 'fontFamilies', 'defaultFontFamilies'),
318 ];
319 }
320
321 /**
322 * Preset slugs from a settings node's origin buckets; drops the `default`
323 * origin when the theme opted the family out.
324 *
325 * @return string[]
326 */
327 private function originSlugs($node, $listKey, $defaultKey)
328 {
329 $list = $node[$listKey] ?? [];
330
331 $origins = ['custom', 'theme'];
332 if (($node[$defaultKey] ?? true) !== false) {
333 $origins[] = 'default';
334 }
335
336 $slugs = [];
337 foreach ($origins as $origin) {
338 foreach ($list[$origin] ?? [] as $item) {
339 if (isset($item['slug'])) {
340 $slugs[] = (string) $item['slug'];
341 }
342 }
343 }
344
345 return array_values(array_unique($slugs));
346 }
347
348 private static function extractColorSlug(string $value)
349 {
350 if (preg_match('/var\(--wp--preset--color--([^)]+)\)/', $value, $m)) {
351 return $m[1];
352 }
353 return null;
354 }
355
356 private static function extractColorPairs()
357 {
358 $styles = \wp_get_global_styles();
359 $settings = \wp_get_global_settings();
360 $custom = $settings['custom'] ?? [];
361
362 $pairs = [];
363 $seen = [];
364
365 $bodyTextSlug = self::extractColorSlug($styles['color']['text'] ?? '');
366 $bodyBgSlug = self::extractColorSlug($styles['color']['background'] ?? '');
367
368 $candidates = [];
369
370 if ($bodyTextSlug && $bodyBgSlug) {
371 $candidates[] = ['text' => $bodyTextSlug, 'bg' => $bodyBgSlug];
372 }
373
374 $btnTextSlug = self::extractColorSlug($styles['elements']['button']['color']['text'] ?? '')
375 ?? self::extractColorSlug($custom['elements']['button']['color']['text'] ?? '');
376 $btnBgSlug = self::extractColorSlug($styles['elements']['button']['color']['background'] ?? '')
377 ?? self::extractColorSlug($custom['elements']['button']['color']['background'] ?? '');
378 if ($btnTextSlug && $btnBgSlug) {
379 $candidates[] = ['text' => $btnTextSlug, 'bg' => $btnBgSlug];
380 }
381
382 $btnHoverTextSlug = self::extractColorSlug($styles['elements']['button'][':hover']['color']['text'] ?? '')
383 ?? self::extractColorSlug($custom['elements']['button'][':hover']['color']['text'] ?? '');
384 $btnHoverBgSlug = self::extractColorSlug($styles['elements']['button'][':hover']['color']['background'] ?? '')
385 ?? self::extractColorSlug($custom['elements']['button'][':hover']['color']['background'] ?? '');
386 if ($btnHoverTextSlug && $btnHoverBgSlug) {
387 $candidates[] = ['text' => $btnHoverTextSlug, 'bg' => $btnHoverBgSlug];
388 }
389
390 $linkTextSlug = self::extractColorSlug($styles['elements']['link']['color']['text'] ?? '')
391 ?? self::extractColorSlug($custom['elements']['link']['color']['text'] ?? '');
392 if ($linkTextSlug && $bodyBgSlug) {
393 $candidates[] = ['text' => $linkTextSlug, 'bg' => $bodyBgSlug];
394 }
395
396 $headingTextSlug = self::extractColorSlug($styles['elements']['heading']['color']['text'] ?? '')
397 ?? self::extractColorSlug($custom['elements']['heading']['color']['text'] ?? '');
398 if ($headingTextSlug && $bodyBgSlug) {
399 $candidates[] = ['text' => $headingTextSlug, 'bg' => $bodyBgSlug];
400 }
401
402 if ($bodyTextSlug) {
403 $candidates[] = ['text' => $bodyTextSlug, 'bg' => 'tertiary'];
404 }
405 if ($headingTextSlug && $headingTextSlug !== $bodyTextSlug) {
406 $candidates[] = ['text' => $headingTextSlug, 'bg' => 'tertiary'];
407 }
408
409 foreach ($candidates as $pair) {
410 $key = $pair['text'] . '|' . $pair['bg'];
411 if (isset($seen[$key])) {
412 continue;
413 }
414 $seen[$key] = true;
415 $pairs[] = $pair;
416 }
417
418 return $pairs;
419 }
420
421 /**
422 * Scan the style dirs to locate if they have variations.
423 * Ported from here:
424 * https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/class-wp-theme-json-resolver.php#L810
425 *
426 * @return bool
427 */
428 private function hasThemeVariations()
429 {
430 $base_directory = get_stylesheet_directory() . '/styles';
431 $template_directory = get_template_directory() . '/styles';
432
433 if (is_dir($base_directory) && glob($base_directory . '/*.json', GLOB_NOSORT)) {
434 return true;
435 }
436
437 // Only check parent if it's different from child
438 if (
439 $template_directory !== $base_directory &&
440 is_dir($template_directory) &&
441 glob($template_directory . '/*.json', GLOB_NOSORT)
442 ) {
443 return true;
444 }
445
446 return false;
447 }
448
449 /**
450 * Get suggestions for the user.
451 *
452 * @return array
453 */
454 private function getSuggestions()
455 {
456 $locale = \get_locale();
457 $cached = \get_option('extendify_agent_suggestions_' . $locale);
458
459 if (!is_array($cached) || !isset($cached['fetchedAt'])) {
460 return $this->refreshSuggestions($locale) ?? [];
461 }
462
463 $age = time() - $cached['fetchedAt'];
464 if ($age > DAY_IN_SECONDS) {
465 if (!\wp_next_scheduled('extendify_agent_suggestions_refresh', [$locale])) {
466 \wp_schedule_single_event(time(), 'extendify_agent_suggestions_refresh', [$locale]);
467 if (\is_admin()) {
468 \spawn_cron();
469 }
470 }
471 }
472
473 return $cached['data'] ?? [];
474 }
475
476 /**
477 * Fetch suggestions from the API and persist them.
478 * Called synchronously on cold start and via wp-cron when cache is stale.
479 *
480 * @param string $locale - Locale to fetch (cron may run in a different site locale).
481 * @return array|null
482 */
483 public function refreshSuggestions($locale)
484 {
485 // When the refresh runs via wp-cron, the active locale may differ from the cached entry's locale.
486 // Switch so HttpClient sends the matching wp_language and the response lands in the right cache key.
487 $needSwitch = $locale !== \get_locale();
488 if ($needSwitch) {
489 \switch_to_locale($locale);
490 }
491
492 $response = HttpClient::post(
493 Constants::AI_HOST . '/api/agent/suggestions',
494 [],
495 null,
496 true
497 );
498
499 if ($needSwitch) {
500 \restore_previous_locale();
501 }
502
503 $optionKey = 'extendify_agent_suggestions_' . $locale;
504
505 if ($response['code'] !== 200) {
506 // Back off: stamp the cache as fetched ~23h ago so we retry in ~1h instead of every request.
507 $cached = \get_option($optionKey);
508 \update_option(
509 $optionKey,
510 [
511 'data' => is_array($cached) ? ($cached['data'] ?? []) : [],
512 'fetchedAt' => time() - (DAY_IN_SECONDS - HOUR_IN_SECONDS),
513 ],
514 false
515 );
516 return null;
517 }
518
519 $suggestions = $response['response']['suggestions'] ?? [];
520 \update_option(
521 $optionKey,
522 ['data' => $suggestions, 'fetchedAt' => time()],
523 false
524 );
525 return $suggestions;
526 }
527
528 /**
529 * Check if the user is in the Gutenberg or FSE editor.
530 *
531 * @return false|bool
532 */
533 public function isGutenbergOrFse()
534 {
535 if (!is_admin() || !function_exists('get_current_screen')) {
536 return false;
537 }
538
539 $screen = get_current_screen();
540 if (!$screen) {
541 return false;
542 }
543
544 $is_fse =
545 in_array($screen->id, ['site-editor', 'appearance_page_gutenberg-edit-site'], true) ||
546 (isset($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'site-editor.php');
547
548 $is_gutenberg =
549 $screen->base === 'post' &&
550 method_exists($screen, 'is_block_editor') &&
551 $screen->is_block_editor();
552
553 return $is_fse || $is_gutenberg;
554 }
555 }
556