PluginProbe
Extendify / 3.1.3
Extendify v3.1.3
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.3, at app/Agent/Admin.php

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