PluginProbe
Extendify / 3.1.0
Extendify v3.1.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.1.0, at app/Agent/Admin.php

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