| 1 |
<?php |
| 2 |
namespace Mediavine\Create; |
| 3 |
|
| 4 |
/** |
| 5 |
* Class for initializing our admin scripts |
| 6 |
*/ |
| 7 |
class Admin_Init extends Plugin { |
| 8 |
|
| 9 |
public static $mcp_data = null; |
| 10 |
|
| 11 |
public static $mv_create_url_params = [ |
| 12 |
'post_type=mv_create', |
| 13 |
'page=mv_settings', |
| 14 |
'page=mv_create_welcome', |
| 15 |
'page=create_home', |
| 16 |
'page=create_editor', |
| 17 |
'page=create_dashboard', |
| 18 |
'page=import', |
| 19 |
'page=theme_elements', |
| 20 |
]; |
| 21 |
|
| 22 |
/** |
| 23 |
* Manages default custom field registration. |
| 24 |
*/ |
| 25 |
public static function custom_fields() { |
| 26 |
$fields = []; |
| 27 |
$fields = apply_filters( 'mv_create_fields', $fields ); |
| 28 |
return $fields; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Gets localization data needed for both Gutenberg and general scripts |
| 33 |
*/ |
| 34 |
public static function localization() { |
| 35 |
global $wpdb; |
| 36 |
$settings = apply_filters( 'mv_create_localized_admin_settings', self::get_translated_settings() ); |
| 37 |
|
| 38 |
// Never ship credential-class values to the browser. The site JWT is |
| 39 |
// reduced to a presence flag for everyone; other credentials (e.g. the |
| 40 |
// Amazon Creators secret) are only kept for users who can manage them. |
| 41 |
$settings = Sensitive_Settings::redact( $settings, current_user_can( 'manage_options' ) ); |
| 42 |
|
| 43 |
$shapes = self::get_translated_shapes(); |
| 44 |
|
| 45 |
self::$mcp_data = self::get_mcp_data(); |
| 46 |
|
| 47 |
$args = [ |
| 48 |
'capability' => [ 'edit_posts' ], |
| 49 |
'fields' => [ 'display_name' ], |
| 50 |
]; |
| 51 |
|
| 52 |
// Capability queries were only introduced in WP 5.9. |
| 53 |
if ( version_compare( $GLOBALS['wp_version'], '5.9', '<' ) ) { |
| 54 |
$args['who'] = 'authors'; |
| 55 |
unset( $args['capability'] ); |
| 56 |
} |
| 57 |
|
| 58 |
$authors = get_users( $args ); |
| 59 |
|
| 60 |
$sanitized_authors = []; |
| 61 |
foreach ( $authors as $author ) { |
| 62 |
if ( ! empty( $author->display_name ) && is_string( $author->display_name ) ) { |
| 63 |
$sanitized_authors[] = $author->display_name; |
| 64 |
} |
| 65 |
} |
| 66 |
|
| 67 |
$key_match_statement = "SELECT id, original_object_id from {$wpdb->prefix}mv_creations WHERE original_object_id IS NOT NULL AND original_object_id != 0"; |
| 68 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- direct $wpdb access on custom/plugin tables; values bound via prepare() where applicable |
| 69 |
$results = $wpdb->get_results( $key_match_statement ); |
| 70 |
$keys = []; |
| 71 |
foreach ( $results as $result ) { |
| 72 |
$keys[ $result->original_object_id ] = $result->id; |
| 73 |
} |
| 74 |
|
| 75 |
$current_user = wp_get_current_user(); |
| 76 |
|
| 77 |
$amazon_provision_lock = (bool) Amazon_Creators::get_transient_timeout( 'mv_create_amazon_provision' ); |
| 78 |
|
| 79 |
return [ |
| 80 |
'__VERSION__' => Plugin::VERSION, |
| 81 |
'__WP_VERSION__' => $GLOBALS['wp_version'], |
| 82 |
'__PHP_VERSION__' => PHP_VERSION, |
| 83 |
'__URL__' => esc_url_raw( rest_url() ), |
| 84 |
'__NONCE__' => wp_create_nonce( 'wp_rest' ), |
| 85 |
'__ADMIN_NONCE__' => wp_create_nonce( 'mv_create_admin' ), |
| 86 |
'__ADMIN_URL__' => esc_url_raw( admin_url() ), |
| 87 |
'__STATIC__' => MV_CREATE_URL . 'ui/static', |
| 88 |
'__SITE_URL__' => esc_url_raw( site_url() ), |
| 89 |
'__CREATE_STUDIO_URL__' => esc_url_raw( self::$create_studio_base_url ), |
| 90 |
'__CURRENT_USER_EMAIL__' => $current_user->user_email, |
| 91 |
'__SETTINGS__' => $settings, |
| 92 |
'__SERVICES_URL__' => esc_url_raw( self::$js_services_api_url ), |
| 93 |
'__SHAPES__' => $shapes, |
| 94 |
'__AUTHORS__' => $sanitized_authors, |
| 95 |
'__MCP__' => self::$mcp_data, |
| 96 |
'__KEY_LOOKUP__' => $keys, |
| 97 |
'__CUSTOM_FIELDS__' => self::custom_fields(), |
| 98 |
'__META_BLOCKS__' => Creations_Meta_Blocks::get_meta_block_slugs(), |
| 99 |
'__USER__' => [ |
| 100 |
'current_user_email' => $current_user->user_email, |
| 101 |
'studio_email' => User_Verification_Meta::get_email( $current_user->ID ), |
| 102 |
'current_firstname' => $current_user->user_firstname, |
| 103 |
'display_name' => $current_user->display_name, |
| 104 |
'current_lastname' => $current_user->user_lastname, |
| 105 |
'avatar_url' => get_avatar_url( $current_user->ID, [ 'size' => 96 ] ), |
| 106 |
'site_url' => esc_url_raw( site_url() ), |
| 107 |
'mediavine_publisher' => self::$mcp_enabled, |
| 108 |
'current_user_authorized' => \Mediavine\Permissions::is_user_authorized(), |
| 109 |
], |
| 110 |
'__FLAGS__' => [ |
| 111 |
'NO_DOM_DOC' => class_exists( 'DOMDocument' ) === false, |
| 112 |
'AMAZON_PROVISION_LOCK' => $amazon_provision_lock, |
| 113 |
'DEV_MODE' => Plugin::is_dev_mode(), |
| 114 |
], |
| 115 |
'__ALLOWED_TYPES__' => [ |
| 116 |
json_decode( \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ) ), |
| 117 |
], |
| 118 |
'__SERVING_ADJUSTMENT_LABEL__' => \Mediavine\Settings::get_setting( 'mv_create_servings_adjustment_label' ), |
| 119 |
]; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Gets fresh settings from the database and returns their translations |
| 124 |
*/ |
| 125 |
private static function get_translated_settings() { |
| 126 |
// force the get_settings call to grab settings fresh from the database |
| 127 |
$saved_settings = (array) \Mediavine\Settings::get_settings(null, null, true); |
| 128 |
if ( 'en_US' === get_locale() ) { |
| 129 |
return $saved_settings; |
| 130 |
} |
| 131 |
$translated_settings = self::get_settings(); |
| 132 |
$saved_slug_keys = []; |
| 133 |
// let's set the array key for each slug of the settings from the DB |
| 134 |
// into a temporary array |
| 135 |
foreach ( $saved_settings as $key => $value ) { |
| 136 |
$saved_slug_keys[ $value->slug ] = $key; |
| 137 |
} |
| 138 |
// loop through all of the translated settings and if the slug exists |
| 139 |
// in our temporary array, use the key in the temp array |
| 140 |
// to update the string data/translations in the settings from the DB. |
| 141 |
foreach ( $translated_settings as $setting ) { |
| 142 |
if ( array_key_exists( $setting['slug'], $saved_slug_keys ) ) { |
| 143 |
$saved_settings[ $saved_slug_keys[ $setting['slug'] ] ]->data = $setting['data']; |
| 144 |
} |
| 145 |
} |
| 146 |
return $saved_settings; |
| 147 |
} |
| 148 |
private static function get_translated_shapes() { |
| 149 |
$shapes = \Mediavine\Create\Shapes::get_shapes(); |
| 150 |
if ( 'en_US' === get_locale() ) { |
| 151 |
return $shapes; |
| 152 |
} |
| 153 |
$translated_shapes = self::get_shapes_data(); |
| 154 |
$saved_slug_keys = []; |
| 155 |
|
| 156 |
// we currently only use the plural string from SHAPES in the UI |
| 157 |
// let's make sure it's translated |
| 158 |
|
| 159 |
// let's set the array key for each slug of the shapes from the DB |
| 160 |
// into a temporary array |
| 161 |
foreach ( $shapes as $key => $value ) { |
| 162 |
$saved_slug_keys[ $value->slug ] = $key; |
| 163 |
} |
| 164 |
// loop through all of the translated shapes and if the slug exists |
| 165 |
// in our temporary array, use the key in the temp array |
| 166 |
// to update the string plural in the shapes from the DB. |
| 167 |
foreach ( $translated_shapes as $shape ) { |
| 168 |
if ( array_key_exists( $shape['slug'], $saved_slug_keys ) ) { |
| 169 |
$shapes[ $saved_slug_keys[ $shape['slug'] ] ]->plural = $shape['plural']; |
| 170 |
} |
| 171 |
} |
| 172 |
return $shapes; |
| 173 |
} |
| 174 |
|
| 175 |
public static function get_current_url() { |
| 176 |
$current_url = null; |
| 177 |
if ( ! empty( $_SERVER['REQUEST_URI'] ) ) { |
| 178 |
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); |
| 179 |
} |
| 180 |
return $current_url; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Whether the current URL matches a Create-owned admin param string. |
| 185 |
* |
| 186 |
* Shared by is_create_admin_url() and is_create_spa_url() so the |
| 187 |
* mv_create_url_params filter + strpos loop lives in one place. |
| 188 |
* |
| 189 |
* @return bool |
| 190 |
*/ |
| 191 |
private static function url_matches_create_params() { |
| 192 |
$current_url = static::get_current_url(); |
| 193 |
if ( ! is_string( $current_url ) || '' === $current_url ) { |
| 194 |
return false; |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* Filters the Create admin URL strings checked against |
| 199 |
* |
| 200 |
* @param array $mv_create_url_params List of URL strings to check |
| 201 |
*/ |
| 202 |
$mv_create_url_params = apply_filters( 'mv_create_url_params', self::$mv_create_url_params ); |
| 203 |
|
| 204 |
foreach ( $mv_create_url_params as $url_param_to_check ) { |
| 205 |
if ( strpos( $current_url, $url_param_to_check ) !== false ) { |
| 206 |
return true; |
| 207 |
} |
| 208 |
} |
| 209 |
|
| 210 |
return false; |
| 211 |
} |
| 212 |
|
| 213 |
/** |
| 214 |
* Check if we are on a Create admin URL |
| 215 |
* |
| 216 |
* @return boolean True if on a Create admin URL |
| 217 |
*/ |
| 218 |
public static function is_create_admin_url() { |
| 219 |
if ( self::url_matches_create_params() ) { |
| 220 |
return true; |
| 221 |
} |
| 222 |
|
| 223 |
$current_url = static::get_current_url(); |
| 224 |
if ( ! is_string( $current_url ) || '' === $current_url ) { |
| 225 |
return false; |
| 226 |
} |
| 227 |
|
| 228 |
// For post.php and post-new.php, check if ANY editor is present |
| 229 |
// This covers all post types including Ultimate Recipe, Osetin, and custom post types |
| 230 |
if ( strpos( $current_url, 'post.php' ) !== false || strpos( $current_url, 'post-new.php' ) !== false ) { |
| 231 |
$screen = get_current_screen(); |
| 232 |
|
| 233 |
// Gutenberg/block editor |
| 234 |
if ( $screen && ! empty( $screen->is_block_editor ) ) { |
| 235 |
return true; |
| 236 |
} |
| 237 |
|
| 238 |
// Classic editor - check if post type supports editor |
| 239 |
if ( $screen && $screen->post_type && post_type_supports( $screen->post_type, 'editor' ) ) { |
| 240 |
return true; |
| 241 |
} |
| 242 |
|
| 243 |
// Fallback: if screen exists and has an edit base, load Create |
| 244 |
if ( $screen && $screen->base === 'post' ) { |
| 245 |
return true; |
| 246 |
} |
| 247 |
|
| 248 |
return false; |
| 249 |
} |
| 250 |
|
| 251 |
return false; |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* Whether the current screen is a Create admin SPA page (not a content post editor). |
| 256 |
* |
| 257 |
* Matches Create-owned URL params (collections, settings, welcome, editor, import, etc.). |
| 258 |
* Post/page editors use the slim blocks bundle instead. |
| 259 |
* |
| 260 |
* @return bool |
| 261 |
*/ |
| 262 |
public static function is_create_spa_url() { |
| 263 |
return self::url_matches_create_params(); |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Script handle for the Create admin SPA (app.build). |
| 268 |
* |
| 269 |
* @return string |
| 270 |
*/ |
| 271 |
public static function app_script_handle() { |
| 272 |
return Plugin::PLUGIN_DOMAIN . '-script'; |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* Script handle for Gutenberg/Classic block registration (blocks.build). |
| 277 |
* |
| 278 |
* @return string |
| 279 |
*/ |
| 280 |
public static function blocks_script_handle() { |
| 281 |
return Plugin::PLUGIN_DOMAIN . '-blocks-script'; |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Build (or Vite-dev) URL for a versioned admin UI artifact. |
| 286 |
* |
| 287 |
* @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'. |
| 288 |
* @return string |
| 289 |
*/ |
| 290 |
public static function admin_ui_script_url( $basename ) { |
| 291 |
$filename = $basename . '.' . self::VERSION . '.js'; |
| 292 |
$origin = Plugin::dev_asset_origin( 'admin' ); |
| 293 |
|
| 294 |
if ( '' !== $origin ) { |
| 295 |
return $origin . '/' . $filename; |
| 296 |
} |
| 297 |
|
| 298 |
return Plugin::assets_url() . 'admin/ui/build/' . $filename; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Register, localize, and enqueue an admin UI script handle. |
| 303 |
* |
| 304 |
* Localization runs at most once per handle per request so Gutenberg screens |
| 305 |
* that hit both enqueue_block_editor_assets and admin_enqueue_scripts do not |
| 306 |
* double the cost of localization() (get_users, SQL, settings, MCP). |
| 307 |
* |
| 308 |
* @param string $handle Script handle. |
| 309 |
* @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'. |
| 310 |
* @param array $deps Script dependencies. |
| 311 |
* @param bool $in_footer Whether to load in footer. |
| 312 |
*/ |
| 313 |
public static function enqueue_admin_ui_script( $handle, $basename, $deps, $in_footer = true ) { |
| 314 |
static $localized = []; |
| 315 |
|
| 316 |
if ( ! wp_script_is( $handle, 'registered' ) ) { |
| 317 |
wp_register_script( |
| 318 |
$handle, |
| 319 |
self::admin_ui_script_url( $basename ), |
| 320 |
$deps, |
| 321 |
self::VERSION, |
| 322 |
$in_footer |
| 323 |
); |
| 324 |
} |
| 325 |
|
| 326 |
// register_create_script() already registers this handle on 'init' pinned to the |
| 327 |
// header, so the wp_register_script() above is skipped and its $in_footer ignored. |
| 328 |
// Set the group directly instead — wp_register_script() does the same thing, and |
| 329 |
// notably does it outside its own "already registered" check. Without this the |
| 330 |
// Classic Editor loads the script in <head>, before the media_buttons hook has |
| 331 |
// emitted the [data-shortcode] node the TinyMCE block mounts into, so the Create |
| 332 |
// insert button silently never renders. |
| 333 |
wp_script_add_data( $handle, 'group', $in_footer ? 1 : 0 ); |
| 334 |
|
| 335 |
if ( empty( $localized[ $handle ] ) ) { |
| 336 |
wp_localize_script( $handle, 'MV_CREATE', self::localization() ); |
| 337 |
wp_set_script_translations( $handle, 'mediavine-create', plugin_dir_path( __DIR__ ) . 'languages/' ); |
| 338 |
$localized[ $handle ] = true; |
| 339 |
} |
| 340 |
|
| 341 |
wp_enqueue_script( $handle ); |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* Outputs the Slate JS Chrome CSS fix if Chrome is detected as browser. |
| 346 |
* |
| 347 |
* While this is unreliable and can be spoofed, we are just using this to output CSS. |
| 348 |
* If we can't detect, then we will output the CSS anyway. Pure CSS solution was found at |
| 349 |
* https://github.com/ianstormtaylor/slate/issues/5119#issuecomment-1264590939. |
| 350 |
*/ |
| 351 |
public function add_slate_chrome_fix() { |
| 352 |
// If no user agent, spoof it as chrome and add CSS anyway, because this info should always |
| 353 |
// be available. |
| 354 |
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'Chrome'; |
| 355 |
|
| 356 |
// If no Chrome, abort. |
| 357 |
if ( ! preg_match( '/Chrome/i', $user_agent ) ) { |
| 358 |
return; |
| 359 |
} |
| 360 |
|
| 361 |
// Make sure Edge isn't mimicking Chrome. |
| 362 |
if ( preg_match( '/Edge/i', $user_agent ) ) { |
| 363 |
return; |
| 364 |
} |
| 365 |
|
| 366 |
// Begin Chrome version check. |
| 367 |
preg_match_all( '/Chrome\/(\d+)/i', $user_agent, $versions ); |
| 368 |
|
| 369 |
// If no verisons found, then we have something funny or spoofed, so add CSS fix anyway. |
| 370 |
if ( empty( $versions[1] ) ) { |
| 371 |
echo '<style>div[data-slate-editor]{-webkit-user-modify: read-write !important;}</style>'; |
| 372 |
|
| 373 |
return; |
| 374 |
} |
| 375 |
|
| 376 |
// Only add CSS fix if Chrome version is 105 or greater. |
| 377 |
foreach ( $versions[1] as $version ) { |
| 378 |
if ( version_compare( (int) $version, 105, '>=' ) ) { |
| 379 |
echo '<style>div[data-slate-editor]{-webkit-user-modify: read-write !important;}</style>'; |
| 380 |
} |
| 381 |
} |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* Enqueues the admin scripts on the page. |
| 386 |
* |
| 387 |
* Create SPA pages get the full app.build bundle. Gutenberg/Classic content |
| 388 |
* post editors get the slim blocks.build bundle (block registration only). |
| 389 |
*/ |
| 390 |
function admin_enqueue_scripts() { |
| 391 |
if ( ! $this::is_create_admin_url() ) { |
| 392 |
return; |
| 393 |
} |
| 394 |
|
| 395 |
wp_enqueue_media(); |
| 396 |
|
| 397 |
// Self-hosted fonts (both SIL OFL 1.1), served from the plugin over the |
| 398 |
// site's own scheme. Loaded wherever Create UI may render (SPA or block modal). |
| 399 |
// Nunito is the primary UI typeface; Fraunces is the display face used |
| 400 |
// for the dashboard headings. |
| 401 |
wp_enqueue_style( |
| 402 |
'mv-font/nunito', |
| 403 |
Plugin::assets_url() . 'assets/fonts/nunito/nunito.css', |
| 404 |
[], |
| 405 |
self::VERSION |
| 406 |
); |
| 407 |
wp_enqueue_style( |
| 408 |
'mv-font/fraunces', |
| 409 |
Plugin::assets_url() . 'assets/fonts/fraunces/fraunces.css', |
| 410 |
[], |
| 411 |
self::VERSION |
| 412 |
); |
| 413 |
|
| 414 |
if ( self::is_create_spa_url() ) { |
| 415 |
$deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ]; |
| 416 |
self::enqueue_admin_ui_script( self::app_script_handle(), 'app.build', $deps, true ); |
| 417 |
} else { |
| 418 |
// Content post editor (Gutenberg or Classic) — slim blocks entry. |
| 419 |
$deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ]; |
| 420 |
if ( function_exists( 'is_gutenberg_page' ) && is_gutenberg_page() ) { |
| 421 |
$deps = array_merge( $deps, [ 'wp-plugins', 'wp-editor' ] ); |
| 422 |
} |
| 423 |
$screen = get_current_screen(); |
| 424 |
$in_footer = true; |
| 425 |
if ( $screen && ! empty( $screen->is_block_editor ) ) { |
| 426 |
$in_footer = false; |
| 427 |
$deps[] = 'wp-edit-post'; |
| 428 |
} |
| 429 |
self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, $in_footer ); |
| 430 |
} |
| 431 |
|
| 432 |
// Add CSS to fix Chrome editor if needed |
| 433 |
$this->add_slate_chrome_fix(); |
| 434 |
} |
| 435 |
|
| 436 |
function admin_head() { |
| 437 |
if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) { |
| 438 |
return; |
| 439 |
} |
| 440 |
|
| 441 |
// check if WYSIWYG is enabled |
| 442 |
if ( 'true' === get_user_option( 'rich_editing' ) ) { |
| 443 |
add_filter( 'tiny_mce_before_init', [ $this, 'tiny_mce_before_init' ] ); |
| 444 |
} |
| 445 |
|
| 446 |
echo '<style>.post-type-mv_create #wpbody #wpbody-content { display: none };</style>'; |
| 447 |
} |
| 448 |
|
| 449 |
function admin_footer() { |
| 450 |
echo '<div id="mv-gb-modal"></div>'; |
| 451 |
} |
| 452 |
|
| 453 |
function admin_menu() { |
| 454 |
$shapes = \Mediavine\Create\Shapes::get_shapes(); |
| 455 |
$allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ); |
| 456 |
$allowed_shapes = json_decode( $allowed_shapes ); |
| 457 |
|
| 458 |
// Router page — hidden submenu entry that redirects to the user's |
| 459 |
// preferred default page via maybe_redirect_default_admin_page(). |
| 460 |
add_submenu_page( |
| 461 |
'edit.php?post_type=mv_create', |
| 462 |
__( 'Create', 'mediavine-create' ), |
| 463 |
__( 'Create', 'mediavine-create' ), |
| 464 |
'edit_posts', |
| 465 |
'create_home', |
| 466 |
'__return_null' |
| 467 |
); |
| 468 |
|
| 469 |
// Dashboard page |
| 470 |
add_submenu_page( |
| 471 |
'edit.php?post_type=mv_create', |
| 472 |
__( 'Dashboard', 'mediavine-create' ), |
| 473 |
__( 'Dashboard', 'mediavine-create' ), |
| 474 |
'manage_options', |
| 475 |
'create_dashboard', |
| 476 |
[ $this, 'dashboard_page' ] |
| 477 |
); |
| 478 |
|
| 479 |
$menu_keys = [ |
| 480 |
'recipe' => __( 'Recipes', 'mediavine-create' ), |
| 481 |
'diy' => __( 'How-Tos', 'mediavine-create' ), |
| 482 |
'list' => __( 'Lists', 'mediavine-create' ), |
| 483 |
]; |
| 484 |
|
| 485 |
// normalize shapes list for backwards compatibility. |
| 486 |
foreach ( $shapes as $card ) { |
| 487 |
if ( |
| 488 |
! array_key_exists( $card->slug, $menu_keys ) || |
| 489 |
( |
| 490 |
! empty( $allowed_shapes ) && ! in_array( $card->slug, $allowed_shapes, true ) |
| 491 |
) |
| 492 |
) { |
| 493 |
continue; |
| 494 |
} |
| 495 |
|
| 496 |
add_submenu_page( |
| 497 |
'edit.php?post_type=mv_create', |
| 498 |
$menu_keys[ $card->slug ], |
| 499 |
$menu_keys[ $card->slug ], |
| 500 |
'manage_options', |
| 501 |
$card->slug, |
| 502 |
[ $this, 'card_page' ] |
| 503 |
); |
| 504 |
} |
| 505 |
|
| 506 |
$static_pages = []; |
| 507 |
$static_pages[ __( 'Recommended Products', 'mediavine-create' ) ] = 'products'; |
| 508 |
$static_pages[ __( 'User Reviews', 'mediavine-create' ) ] = 'reviews'; |
| 509 |
|
| 510 |
foreach ( $static_pages as $label => $value ) { |
| 511 |
add_submenu_page( |
| 512 |
'edit.php?post_type=mv_create', |
| 513 |
$label, |
| 514 |
$label, |
| 515 |
'manage_options', |
| 516 |
$value, |
| 517 |
[ $this, 'card_page' ] |
| 518 |
); |
| 519 |
} |
| 520 |
|
| 521 |
add_submenu_page( |
| 522 |
'edit.php?post_type=mv_create', |
| 523 |
__( 'Create Plugin Settings', 'mediavine-create' ), |
| 524 |
__( 'Settings', 'mediavine-create' ), |
| 525 |
'manage_options', |
| 526 |
'settings', |
| 527 |
[ $this, 'menu_page' ] |
| 528 |
); |
| 529 |
|
| 530 |
add_options_page( |
| 531 |
__( 'Create Plugin Settings', 'mediavine-create' ), |
| 532 |
__( 'Create', 'mediavine-create' ), |
| 533 |
'manage_options', |
| 534 |
'mv_settings', |
| 535 |
[ $this, 'menu_page' ] |
| 536 |
); |
| 537 |
|
| 538 |
// Editor page under Create menu. Registered under the real parent so |
| 539 |
// WordPress keeps the submenu open and handles capabilities correctly. |
| 540 |
// The menu item is hidden via CSS in editor_hide_menu_item(). |
| 541 |
add_submenu_page( |
| 542 |
'edit.php?post_type=mv_create', |
| 543 |
__( 'Edit Create Card', 'mediavine-create' ), |
| 544 |
__( 'Edit Card', 'mediavine-create' ), |
| 545 |
'edit_posts', |
| 546 |
'create_editor', |
| 547 |
[ $this, 'editor_page' ] |
| 548 |
); |
| 549 |
|
| 550 |
// Hidden welcome page (no menu item) |
| 551 |
add_submenu_page( |
| 552 |
'', |
| 553 |
__( 'Welcome to Create 2.0', 'mediavine-create' ), |
| 554 |
__( 'Welcome', 'mediavine-create' ), |
| 555 |
'manage_options', |
| 556 |
'mv_create_welcome', |
| 557 |
[ $this, 'welcome_page' ] |
| 558 |
); |
| 559 |
|
| 560 |
// Theme Elements page (dev mode only) |
| 561 |
if ( Plugin::is_dev_mode() ) { |
| 562 |
add_submenu_page( |
| 563 |
'edit.php?post_type=mv_create', |
| 564 |
__( 'Theme Elements', 'mediavine-create' ), |
| 565 |
__( 'Theme Elements', 'mediavine-create' ), |
| 566 |
'manage_options', |
| 567 |
'theme_elements', |
| 568 |
[ $this, 'theme_elements_page' ] |
| 569 |
); |
| 570 |
} |
| 571 |
|
| 572 |
// Reorder submenu and set the parent menu href to the router page. |
| 573 |
// |
| 574 |
// WordPress uses $submenu[$parent][0][2] as the href for the top-level |
| 575 |
// menu item. For CPT menus, position 0 is auto-generated as "All {type}" |
| 576 |
// with the slug set to the full parent URL (e.g. edit.php?post_type=mv_create). |
| 577 |
// We change position 0's slug to the router URL so the parent menu link |
| 578 |
// goes through the router, then add "All Create Cards" as its own entry. |
| 579 |
global $submenu; |
| 580 |
$parent = 'edit.php?post_type=mv_create'; |
| 581 |
if ( isset( $submenu[ $parent ] ) ) { |
| 582 |
$dashboard = null; |
| 583 |
|
| 584 |
foreach ( $submenu[ $parent ] as $key => $item ) { |
| 585 |
if ( 'create_dashboard' === $item[2] ) { |
| 586 |
$dashboard = $item; |
| 587 |
unset( $submenu[ $parent ][ $key ] ); |
| 588 |
} elseif ( 'create_home' === $item[2] ) { |
| 589 |
unset( $submenu[ $parent ][ $key ] ); |
| 590 |
} |
| 591 |
} |
| 592 |
|
| 593 |
$submenu[ $parent ] = array_values( $submenu[ $parent ] ); |
| 594 |
|
| 595 |
// Position 0 is the auto-generated "All Create Cards" entry. |
| 596 |
// Change its slug to the router URL so clicking the top-level |
| 597 |
// "Create" menu item goes through the router. Keep the label. |
| 598 |
if ( isset( $submenu[ $parent ][0] ) ) { |
| 599 |
$all_cards_label = $submenu[ $parent ][0][0]; |
| 600 |
$all_cards_cap = $submenu[ $parent ][0][1]; |
| 601 |
|
| 602 |
// Point position 0 to the router page (full URL so WP uses it directly). |
| 603 |
$submenu[ $parent ][0][2] = 'edit.php?post_type=mv_create&page=create_home'; |
| 604 |
|
| 605 |
// Re-add "All Create Cards" as a real submenu entry at position 2. |
| 606 |
$all_cards_item = [ $all_cards_label, $all_cards_cap, $parent ]; |
| 607 |
array_splice( $submenu[ $parent ], 1, 0, [ $all_cards_item ] ); |
| 608 |
} |
| 609 |
|
| 610 |
// Insert Dashboard at position 1 (right after the router, before All Cards). |
| 611 |
if ( $dashboard ) { |
| 612 |
array_splice( $submenu[ $parent ], 1, 0, [ $dashboard ] ); |
| 613 |
} |
| 614 |
} |
| 615 |
} |
| 616 |
|
| 617 |
function card_page() { |
| 618 |
$screen_object = get_current_screen(); |
| 619 |
$exploded = explode( '_', $screen_object->base ); |
| 620 |
$position = count( $exploded ) - 1; |
| 621 |
$type = $exploded[ $position ]; |
| 622 |
?> |
| 623 |
<div id="MVRoot" data-type="<?php echo esc_html( $type ); ?>"></div> |
| 624 |
<?php |
| 625 |
} |
| 626 |
|
| 627 |
// Blank function prevents PHP notice |
| 628 |
function menu_page() {} |
| 629 |
|
| 630 |
function dashboard_page() { |
| 631 |
?> |
| 632 |
<div id="MVRoot" data-page="dashboard"></div> |
| 633 |
<?php |
| 634 |
} |
| 635 |
|
| 636 |
function editor_page() { |
| 637 |
?> |
| 638 |
<div id="MVRoot" data-page="editor"></div> |
| 639 |
<?php |
| 640 |
} |
| 641 |
|
| 642 |
function welcome_page() { |
| 643 |
?> |
| 644 |
<div id="MVRoot" data-page="welcome"></div> |
| 645 |
<?php |
| 646 |
} |
| 647 |
|
| 648 |
function theme_elements_page() { |
| 649 |
?> |
| 650 |
<div id="MVRoot" data-page="theme-elements"></div> |
| 651 |
<?php |
| 652 |
} |
| 653 |
|
| 654 |
function media_buttons( $editor_id ) { |
| 655 |
if ( 'content' !== $editor_id ) { |
| 656 |
return; |
| 657 |
} |
| 658 |
?> |
| 659 |
<div data-shortcode="mv_create"></div> |
| 660 |
<?php |
| 661 |
} |
| 662 |
|
| 663 |
/** |
| 664 |
* Adds Create styles to TinyMCE (Classic Editor) load |
| 665 |
* |
| 666 |
* @param array $mceInit An array with TinyMCE config. |
| 667 |
* @return array |
| 668 |
*/ |
| 669 |
function tiny_mce_before_init( $mceInit ) { |
| 670 |
// Prevent PHP errors/notices as this can be filtered by other plugins |
| 671 |
if ( ! is_array( $mceInit ) ) { |
| 672 |
return $mceInit; |
| 673 |
} |
| 674 |
$content_css = Plugin::assets_url() . 'admin/ui/static/tinymce.css?' . self::VERSION; |
| 675 |
if ( ! empty( $mceInit['content_css'] ) ) { |
| 676 |
$mceInit['content_css'] .= ', ' . $content_css; |
| 677 |
} else { |
| 678 |
$mceInit['content_css'] = $content_css; |
| 679 |
} |
| 680 |
|
| 681 |
return $mceInit; |
| 682 |
} |
| 683 |
|
| 684 |
/** |
| 685 |
* Register the block categories. |
| 686 |
* |
| 687 |
* @param array $categories An array of categories available to the editors |
| 688 |
* @return array $categories |
| 689 |
*/ |
| 690 |
public function block_categories( $categories = [] ) { |
| 691 |
// TODO: the following page check should no longer be needed once a fix for the following issue is released |
| 692 |
// https://github.com/WordPress/gutenberg/issues/28517 |
| 693 |
global $pagenow; |
| 694 |
if ( 'widgets.php' === $pagenow || 'customize.php' === $pagenow ) { |
| 695 |
// This is a widgets block editor. We only want our blocks registered for post/page editors. |
| 696 |
return $categories; |
| 697 |
} else { |
| 698 |
$merged = array_merge( |
| 699 |
$categories, |
| 700 |
[ |
| 701 |
[ |
| 702 |
'slug' => 'mediavine-create', |
| 703 |
'title' => __( 'Create', 'mediavine-create' ), |
| 704 |
'icon' => 'mediavine', |
| 705 |
], |
| 706 |
] |
| 707 |
); |
| 708 |
return $merged; |
| 709 |
} |
| 710 |
} |
| 711 |
|
| 712 |
/** |
| 713 |
* Register the slim blocks script early so it can be referenced by block registration. |
| 714 |
* This runs on 'init' before register_gutenberg_blocks. |
| 715 |
*/ |
| 716 |
function register_create_script() { |
| 717 |
wp_register_script( |
| 718 |
self::blocks_script_handle(), |
| 719 |
self::admin_ui_script_url( 'blocks.build' ), |
| 720 |
[ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-components', 'wp-api-fetch', 'wp-data' ], |
| 721 |
self::VERSION, |
| 722 |
false // Load in header |
| 723 |
); |
| 724 |
} |
| 725 |
|
| 726 |
/** |
| 727 |
* Register Gutenberg blocks server-side |
| 728 |
* |
| 729 |
* This server-side registration provides: |
| 730 |
* - Block type recognition so Gutenberg doesn't show "unsupported block" errors |
| 731 |
* - Attribute schema for proper serialization |
| 732 |
* - Render callback for frontend output |
| 733 |
* - Script dependency so WordPress loads our script when blocks are used |
| 734 |
*/ |
| 735 |
function register_gutenberg_blocks() { |
| 736 |
// Get allowed types from settings |
| 737 |
$allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ); |
| 738 |
$allowed_shapes = json_decode( $allowed_shapes ); |
| 739 |
// Default to all types if none specified |
| 740 |
if ( empty( $allowed_shapes ) ) { |
| 741 |
$allowed_shapes = ['recipe', 'list', 'diy']; |
| 742 |
} |
| 743 |
|
| 744 |
// Register blocks for each allowed type |
| 745 |
foreach ( $allowed_shapes as $type ) { |
| 746 |
$block_name = "mv/{$type}"; |
| 747 |
|
| 748 |
// Skip if block is already registered (prevents re-registration in test environments) |
| 749 |
if ( \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) { |
| 750 |
continue; |
| 751 |
} |
| 752 |
|
| 753 |
register_block_type( $block_name, [ |
| 754 |
'api_version' => 3, |
| 755 |
'editor_script' => self::blocks_script_handle(), |
| 756 |
'render_callback' => [ $this, 'render_block' ], |
| 757 |
'attributes' => [ |
| 758 |
'id' => [ |
| 759 |
'type' => 'number', |
| 760 |
], |
| 761 |
'title' => [ |
| 762 |
'type' => 'string', |
| 763 |
'default' => '', |
| 764 |
], |
| 765 |
'thumbnail_uri' => [ |
| 766 |
'type' => 'string', |
| 767 |
'default' => '', |
| 768 |
], |
| 769 |
'type' => [ |
| 770 |
'type' => 'string', |
| 771 |
'default' => $type, |
| 772 |
], |
| 773 |
'layout' => [ |
| 774 |
'type' => 'string', |
| 775 |
], |
| 776 |
], |
| 777 |
] ); |
| 778 |
} |
| 779 |
} |
| 780 |
|
| 781 |
/** |
| 782 |
* Render callback for Gutenberg blocks |
| 783 |
*/ |
| 784 |
function render_block( $attributes ) { |
| 785 |
$id = isset( $attributes['id'] ) ? $attributes['id'] : ''; |
| 786 |
$title = isset( $attributes['title'] ) ? $attributes['title'] : ''; |
| 787 |
$thumbnail = isset( $attributes['thumbnail_uri'] ) ? $attributes['thumbnail_uri'] : ''; |
| 788 |
$type = isset( $attributes['type'] ) ? $attributes['type'] : 'recipe'; |
| 789 |
$layout = isset( $attributes['layout'] ) ? $attributes['layout'] : ''; |
| 790 |
|
| 791 |
// Build shortcode |
| 792 |
$shortcode = "[mv_create key=\"{$id}\" type=\"{$type}\" title=\"{$title}\" thumbnail=\"{$thumbnail}\""; |
| 793 |
if ( !empty( $layout ) ) { |
| 794 |
$shortcode .= " layout=\"{$layout}\""; |
| 795 |
} |
| 796 |
$shortcode .= "]"; |
| 797 |
|
| 798 |
return $shortcode; |
| 799 |
} |
| 800 |
|
| 801 |
/** |
| 802 |
* Enqueue scripts specifically for the block editor. |
| 803 |
* This runs at the right time for Gutenberg integration. |
| 804 |
*/ |
| 805 |
function enqueue_block_editor_assets() { |
| 806 |
$deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-edit-post', 'wp-api-fetch', 'wp-data' ]; |
| 807 |
self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, false ); |
| 808 |
} |
| 809 |
|
| 810 |
/** |
| 811 |
* Highlight the correct submenu item (Recipes, How-Tos, Lists) when on the editor page. |
| 812 |
* |
| 813 |
* @param string $submenu_file The submenu file slug. |
| 814 |
* @return string |
| 815 |
*/ |
| 816 |
function editor_submenu_file( $submenu_file ) { |
| 817 |
global $plugin_page; |
| 818 |
if ( 'create_editor' === $plugin_page ) { |
| 819 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 820 |
$type = isset( $_GET['type'] ) ? sanitize_text_field( wp_unslash( $_GET['type'] ) ) : ''; |
| 821 |
if ( $type ) { |
| 822 |
return $type; |
| 823 |
} |
| 824 |
} |
| 825 |
return $submenu_file; |
| 826 |
} |
| 827 |
|
| 828 |
/** |
| 829 |
* Hide the "Edit Card" link from the Create submenu sidebar via CSS. |
| 830 |
* |
| 831 |
* The editor page is registered under the real Create parent so WordPress |
| 832 |
* can properly highlight the menu. But we don't want the "Edit Card" link |
| 833 |
* visible in the sidebar — it's accessed via collection/card links. |
| 834 |
*/ |
| 835 |
function editor_hide_menu_item() { |
| 836 |
?> |
| 837 |
<style> |
| 838 |
#adminmenu .wp-submenu a[href*="page=create_home"], |
| 839 |
#adminmenu a[href*="page=create_editor"] { display: none !important; } |
| 840 |
</style> |
| 841 |
<?php |
| 842 |
} |
| 843 |
|
| 844 |
/** |
| 845 |
* Redirect post.php Create editor URLs to admin.php?page=create_editor |
| 846 |
* and repair bad object_id values on the editor page. |
| 847 |
* |
| 848 |
* WordPress core's post.php validates the post type of the loaded post before |
| 849 |
* our React app can mount. If a creation's object_id points to a non-mv_create |
| 850 |
* post (e.g., wprm_recipe from an old import), post.php dies with "Invalid post type." |
| 851 |
* |
| 852 |
* This hook: |
| 853 |
* 1. Redirects post.php?post_type=mv_create URLs to admin.php?page=create_editor |
| 854 |
* to avoid WordPress core's post type validation entirely. |
| 855 |
* 2. On the admin.php editor page, repairs the creation's object_id if it doesn't |
| 856 |
* point to a valid mv_create post. |
| 857 |
*/ |
| 858 |
function maybe_repair_creation_object_id() { |
| 859 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check |
| 860 |
$current_url = static::get_current_url(); |
| 861 |
if ( ! $current_url ) { |
| 862 |
return; |
| 863 |
} |
| 864 |
|
| 865 |
// Redirect post.php Create editor URLs to admin.php?page=create_editor |
| 866 |
if ( |
| 867 |
strpos( $current_url, 'post.php' ) !== false && |
| 868 |
strpos( $current_url, 'post_type=mv_create' ) !== false && |
| 869 |
strpos( $current_url, 'action=edit' ) !== false |
| 870 |
) { |
| 871 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 872 |
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : null; |
| 873 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 874 |
$type = isset( $_GET['type'] ) ? sanitize_text_field( wp_unslash( $_GET['type'] ) ) : null; |
| 875 |
|
| 876 |
if ( $id && $type ) { |
| 877 |
$redirect_url = admin_url( 'admin.php?page=create_editor&id=' . $id . '&type=' . $type ); |
| 878 |
wp_safe_redirect( $redirect_url ); |
| 879 |
exit; |
| 880 |
} |
| 881 |
} |
| 882 |
|
| 883 |
// On the admin.php editor page, repair bad object_id values |
| 884 |
if ( strpos( $current_url, 'page=create_editor' ) === false ) { |
| 885 |
return; |
| 886 |
} |
| 887 |
|
| 888 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 889 |
$creation_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : null; |
| 890 |
if ( ! $creation_id ) { |
| 891 |
return; |
| 892 |
} |
| 893 |
|
| 894 |
// Look up the creation |
| 895 |
$creation = self::$models_v2->mv_creations->find_one( $creation_id ); |
| 896 |
if ( ! $creation || ! isset( $creation->object_id ) ) { |
| 897 |
return; |
| 898 |
} |
| 899 |
|
| 900 |
// Check if object_id points to a valid mv_create post |
| 901 |
$post = get_post( $creation->object_id ); |
| 902 |
if ( $post && 'mv_create' === $post->post_type ) { |
| 903 |
return; // Already correct |
| 904 |
} |
| 905 |
|
| 906 |
// object_id is missing or points to wrong post type — create a new mv_create post |
| 907 |
$mv_create_post_id = wp_insert_post( |
| 908 |
[ |
| 909 |
'post_title' => $creation->title ?? '', |
| 910 |
'post_type' => 'mv_create', |
| 911 |
'post_status' => 'publish', |
| 912 |
], |
| 913 |
true |
| 914 |
); |
| 915 |
|
| 916 |
if ( is_wp_error( $mv_create_post_id ) ) { |
| 917 |
return; |
| 918 |
} |
| 919 |
|
| 920 |
// Update the creation's object_id |
| 921 |
self::$models_v2->mv_creations->update_without_modified_date( |
| 922 |
[ |
| 923 |
'id' => $creation_id, |
| 924 |
'object_id' => $mv_create_post_id, |
| 925 |
] |
| 926 |
); |
| 927 |
} |
| 928 |
|
| 929 |
/** |
| 930 |
* Redirect the create_home router page to the user's preferred admin page. |
| 931 |
* |
| 932 |
* Runs on admin_init (before output) so we can safely redirect. |
| 933 |
*/ |
| 934 |
function maybe_redirect_default_admin_page() { |
| 935 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 936 |
if ( empty( $_GET['page'] ) || 'create_home' !== $_GET['page'] ) { |
| 937 |
return; |
| 938 |
} |
| 939 |
|
| 940 |
$default_page = \Mediavine\Settings::get_setting( 'mv_create_default_admin_page' ); |
| 941 |
|
| 942 |
$slug_map = [ |
| 943 |
'dashboard' => 'create_dashboard', |
| 944 |
'all_cards' => null, // special case — go to the CPT list |
| 945 |
'recipe' => 'recipe', |
| 946 |
'diy' => 'diy', |
| 947 |
'list' => 'list', |
| 948 |
]; |
| 949 |
|
| 950 |
// Default to dashboard if the setting is empty or unrecognised. |
| 951 |
if ( empty( $default_page ) || ! array_key_exists( $default_page, $slug_map ) ) { |
| 952 |
$default_page = 'dashboard'; |
| 953 |
} |
| 954 |
|
| 955 |
if ( null === $slug_map[ $default_page ] ) { |
| 956 |
// "All Cards" — the CPT list table with no page param. |
| 957 |
$url = admin_url( 'edit.php?post_type=mv_create' ); |
| 958 |
} else { |
| 959 |
$url = admin_url( 'edit.php?post_type=mv_create&page=' . $slug_map[ $default_page ] ); |
| 960 |
} |
| 961 |
|
| 962 |
wp_safe_redirect( $url ); |
| 963 |
exit; |
| 964 |
} |
| 965 |
|
| 966 |
function init() { |
| 967 |
global $wp_version; |
| 968 |
// version-check for filter compatibility |
| 969 |
$block_categories_filter = 'block_categories'; |
| 970 |
if ( version_compare( $wp_version, '5.8', '>=' ) ) { |
| 971 |
$block_categories_filter = 'block_categories_all'; |
| 972 |
} |
| 973 |
|
| 974 |
add_action( 'admin_init', [ $this, 'maybe_redirect_default_admin_page' ] ); |
| 975 |
add_action( 'admin_init', [ $this, 'maybe_repair_creation_object_id' ] ); |
| 976 |
add_filter( 'submenu_file', [ $this, 'editor_submenu_file' ] ); |
| 977 |
add_action( 'admin_head', [ $this, 'editor_hide_menu_item' ] ); |
| 978 |
add_action( 'admin_head', [ $this, 'admin_head' ] ); |
| 979 |
add_action( 'admin_footer', [ $this, 'admin_footer' ] ); |
| 980 |
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ], 11 ); |
| 981 |
add_action( 'admin_menu', [ $this, 'admin_menu' ] ); |
| 982 |
add_action( 'media_buttons', [ $this, 'media_buttons' ] ); |
| 983 |
// Register script first (priority 5), then blocks (priority 10) |
| 984 |
add_action( 'init', [ $this, 'register_create_script' ], 5 ); |
| 985 |
add_action( 'init', [ $this, 'register_gutenberg_blocks' ], 10 ); |
| 986 |
add_filter( $block_categories_filter, [ $this, 'block_categories' ], 10, 1 ); |
| 987 |
// Also enqueue for block editor specifically with early priority |
| 988 |
add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_block_editor_assets' ], 1 ); |
| 989 |
} |
| 990 |
|
| 991 |
} |
| 992 |
|