PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
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.1.4, at app/Agent/Admin.php

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