Begin XenForo Sync and continue Uploader

This commit is contained in:
2026-01-24 14:43:58 +01:00
parent 86b70562bc
commit f029371a68
21 changed files with 722 additions and 21 deletions

View File

@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"build:submissions-uploader": "vite build --config vite-configs/vite.submissions-uploader.config.js",
"build:rhpz-url": "vite build --config vite-configs/vite.rhpz-url.config.js",
"build:send-notification": "vite build --config vite-configs/vite.send-notification.config.js",
"build:manage-notifications": "vite build --config vite-configs/vite.manage-notifications.config.js",

View File

@@ -181,6 +181,26 @@ class Settings extends Abstract_Page {
'discord',
);
add_settings_section(
'xf',
__( 'XenForo', 'romhackplaza' ),
function(){},
'romhackplaza-config'
);
add_settings_field(
'xf_auto_create_user',
__( 'Automatic XenForo account creation if user don\'t have XF ID', 'romhackplaza' ),
function(){
global $_romhackplaza;
$value = esc_attr( $_romhackplaza->settings->get( 'xf_auto_create_user' ) ?? '0' );
echo sprintf( '<input type="checkbox" name="%1$s[%2$s]" value="1" %3$s />', 'romhackplaza_plugin_options', 'xf_auto_create_user', checked( $value, '1', false ) );
},
'romhackplaza-config',
'xf',
);
}
/* ---

View File

@@ -7,7 +7,7 @@ defined( '\ABSPATH' ) || exit;
class Properties extends Abstract_Extender {
public static \WP_Post $current_post;
public static \WP_Post|null $current_post;
protected function can_extend(): bool
{
@@ -23,7 +23,7 @@ class Properties extends Abstract_Extender {
}
public static function change_current_post( \WP_Post $post ): void {
public static function change_current_post( \WP_Post|null $post ): void {
static::$current_post = $post;

View File

@@ -111,12 +111,12 @@ class Submissions extends Abstract_Extender {
'submissions-uploader',
ROMHACKPLAZA_PLUGIN_URI . '/assets/js/submissions/uploader.js',
[],
'20251106',
'20251106-68',
args: [ 'defer' => true, 'in_footer' => true ]
)->enqueue()->add_localize(
'_romhackplaza_script_uploader',
[ 'submit_url' => admin_url( 'admin-ajax.php' ) ],
);
)->enable_i18n();
}

View File

@@ -0,0 +1,233 @@
<?php
namespace RomhackPlaza\Extenders\XenForo;
defined( 'ABSPATH' ) || exit;
class API {
const string API_LINK = "api/";
public static function _build_url( string $endpoint, array $get_args = [] ): string {
return add_query_arg(
$get_args,
$_ENV['ROMHACKPLAZA_XF_URL'] . self::API_LINK . $endpoint
);
}
private static function _do_request(
string $action_type,
string $url,
array $body = [],
array $headers = [],
bool $need_xf_api_user = false
){
$wp_args = [
'headers' => [
'XF-Api-Key' => $_ENV['ROMHACKPLAZA_XF_SUPER_USER_API_KEY'],
'Content-Type' => 'application/json',
],
];
if( $action_type !== 'GET' )
$wp_args['body'] = json_encode( $body );
if( $need_xf_api_user )
$wp_args['headers']['XF-Api-User'] = 1;
$wp_args['headers'] = wp_parse_args( $headers, $wp_args['headers'] );
$response = null;
switch ( $action_type ) {
case 'GET':
$response = wp_remote_get( $url, $wp_args );
break;
case 'POST':
$response = wp_remote_post( $url, $wp_args );
break;
default:
$wp_args['method'] = $action_type;
$response = wp_remote_request( $url, $wp_args );
break;
}
if( !empty( $response->errors ) ){
echo "<script>console.error( `An error occured during a XenForo request.` + `". json_encode( $response->errors ) . "`);</script>";
$response = $response->errors;
if( is_array( $response ) )
$response['api_error'] = true;
} else {
$response = json_decode( wp_remote_retrieve_body( $response ), ARRAY_A );
if( is_array( $response ) )
$response['api_error'] = false;
}
return $response;
}
private static function _error_occured( mixed $response ): bool {
if( is_array( $response ) && isset( $response['api_error'] ) && $response['api_error'] === true )
return true;
return false;
}
/*
* API Methods
*/
public static function post__alerts(
int $to_xf_user_id,
string $message,
string $url = "",
string $url_title = "",
int $from_xf_user_id = 0,
): array|null {
$url = self::_build_url( 'alerts' );
$body = [
'to_user_id' => $to_xf_user_id,
'alert' => $message,
'from_user_id' => $from_xf_user_id,
'link_url' => $url,
'link_title' => $url_title,
];
$response = self::_do_request( 'POST', $url, $body, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function post__auth_login_token(
int $xf_user_id,
array $body = [],
): array|null {
if( !isset( $body['user_id'] ) )
$body['user_id'] = $xf_user_id;
$url = self::_build_url( 'auth/login-token' );
$response = self::_do_request( 'POST', $url, $body );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function get__index(
): array|null {
$url = self::_build_url( 'index' );
$response = self::_do_request( 'GET', $url );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function get__me(
): array|null {
$url = self::_build_url( "me" );
$response = self::_do_request( 'GET', $url );
if( self::_error_occured( $response ) )
return null;
return $response['me'];
}
public static function get__users_id(
int $xf_user_id,
array $body = []
){
$url = self::_build_url( "users/{$xf_user_id}", $body );
$response = self::_do_request( 'GET', $url, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function post__users(
array $body
){
$url = self::_build_url( "users" );
$response = self::_do_request( 'POST', $url, $body, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function post__users_id(
int $xf_user_id,
array $body
){
$url = self::_build_url( "users/{$xf_user_id}" );
$response = self::_do_request( 'POST', $url, $body, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
/*
* Custom API methods
*/
public static function get__plaza_alerts_id(
int $xf_user_id = 0,
): array|null {
$url = self::_build_url( "plaza-alerts/{$xf_user_id}" );
$response = self::_do_request( 'GET', $url, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
public static function post__plaza_reports(
int $entry_id,
string $entry_title,
string $entry_url,
int $xf_user_id = 0,
string $username = "Guest",
string $email = "",
string $message = "",
){
$url = self::_build_url( "plaza-reports" );
$body = [
'entry_id' => $entry_id,
'entry_title' => $entry_title,
'entry_url' => $entry_url,
'xf_user_id' => $xf_user_id,
'username' => $username,
'email' => $email,
'message' => $message,
];
$response = self::_do_request( 'POST', $url, $body, need_xf_api_user: true );
if( self::_error_occured( $response ) )
return null;
return $response;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace RomhackPlaza\Extenders\XenForo;
use RomhackPlaza\Extenders\Abstract_Extender;
abstract class Abstract_XenForo_Children extends Abstract_Extender {
protected function can_extend(): bool
{
return true;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace RomhackPlaza\Extenders\XenForo;
defined( 'ABSPATH' ) || exit;
class Auth extends Abstract_XenForo_Children {
protected function extend(): void
{
$this->add_action('wp_login', [ $this, 'auth_on_xenforo' ], 50, 2 );
}
private function verify_xenforo_auth( \WP_User $user, int $xf_user_id ){
$response = API::get__users_id( $xf_user_id, [ 'with_posts' => false ] );
if( $response != null && isset( $response['user']['email'] ) ){
return $response['user']['email'] === $user->user_email;
}
return false;
}
public function auth_on_xenforo( string $username, \WP_User $wp_user ) {
$xf_user_id = absint( get_the_author_meta( XenForo::XF_USER_ID, $wp_user->ID ) ?? 0 );
if( $xf_user_id == 0 )
return; // Disable login...
if( !$this->verify_xenforo_auth( $wp_user, $xf_user_id ) )
return;
$response = API::post__auth_login_token( $xf_user_id, [
'user_id' => $xf_user_id,
'return_url' => home_url(),
'force' => true,
] );
if( $response != null && isset( $response['login_url'] ) ) {
if( wp_redirect( $response['login_url'] ) ) {
exit;
}
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace RomhackPlaza\Extenders\XenForo;
use RomhackPlaza\Overrides\Roles;
use WP_User;
defined( 'ABSPATH' ) || exit;
class User_Settings extends Abstract_XenForo_Children {
const array USER_ROLE_SYNC = [
Roles::Read_Only->value => 5,
Roles::Member->value => 2,
Roles::Verified->value => 6,
Roles::Moderator->value => 4,
Roles::Administrator->value => 3,
Roles::Bot->value => 7
];
protected function extend(): void
{
$this->add_action( 'show_user_profile', [ $this, 'xf_user_id_field'] );
$this->add_action( 'edit_user_profile', [ $this, 'xf_user_id_field'] );
$this->add_action('personal_options_update', [ $this, 'update_xf_user_id_from_profile'] );
$this->add_action('edit_user_profile_update', [ $this, 'update_xf_user_id_from_profile'] );
$this->add_action( 'profile_update', [ $this, 'sync_user_profile' ], 20, 2 );
}
public function xf_user_id_field( \WP_User $user ): void {
global $_romhackplaza;
if( !$_romhackplaza->user_roles->current_is_staff() )
return;
$field_value = absint( get_the_author_meta( XenForo::XF_USER_ID, $user->ID ) ?? 0 );
?>
<h3 class="heading"><?php _e( "XenForo Config", "romhackplaza" ); ?></h3>
<table class="form-table">
<tr>
<th><label for="<?php echo XenForo::XF_USER_ID; ?>"><?php _e( "XF User ID", "romhackplaza" ); ?></label></th>
<td>
<input type="text" name="<?php echo XenForo::XF_USER_ID; ?>" id="<?php echo XenForo::XF_USER_ID; ?>" value="<?php echo $field_value != 0 ? strval( $field_value ) : ''; ?>" />
</td>
</tr>
</table>
<?php
}
public function update_xf_user_id_from_profile( int $user_id ): void {
global $_romhackplaza;
if( !$_romhackplaza->user_roles->current_is_staff() ) // Can't update this field if not staff member.
return;
if( isset( $_POST[ XenForo::XF_USER_ID ] ) ){
$field_value = absint( $_POST[ XenForo::XF_USER_ID ] ) ?? 0;
if( $field_value != 0 )
update_user_meta( $user_id, XenForo::XF_USER_ID, $field_value );
}
}
private function prepare_sync_user_role( string $role_name, int $user_id ): array{
global $_romhackplaza;
return [
'user_group_id' => self::USER_ROLE_SYNC[$role_name],
'is_staff' => $_romhackplaza->user_roles->user_is_staff( $user_id )
];
}
public function sync_user_role( int $user_id ){
global $_romhackplaza;
$role = $_romhackplaza->user_roles->user_upper( $user_id );
if( $role == null )
return;
$xf_user_id = absint( get_the_author_meta( XenForo::XF_USER_ID, $user_id ) );
if( $xf_user_id == 0 )
return;
API::post__users_id( $xf_user_id, self::prepare_sync_user_role( $role->value, $user_id ) );
}
public function create_xenforo_account_from_wp_user( int $user_id ){
global $_romhackplaza;
if( $_romhackplaza->settings->get( 'xf_auto_create_user' ) != '1' )
return;
$wp_user = get_user_by( 'id', $user_id );
if( !$wp_user )
return;
$create_user_array = [];
$create_user_array['username'] = $wp_user->display_name;
$role = $_romhackplaza->user_roles->user_upper( $user_id );
if( $role != null )
$create_user_array = array_merge( $create_user_array, self::prepare_sync_user_role( $role->value, $user_id ) );
$create_user_array['email'] = sanitize_email( $wp_user->user_email );
$create_user_array['password'] = wp_generate_password();
$create_user_array['custom_fields']['wp_user_id'] = $user_id;
$create_user_array['custom_fields']['wp_entries_number'] = 0;
$response = API::post__users( $create_user_array );
if( $response != null && $response['success'] === true && isset( $response['user']['user_id'] ) ){
update_user_meta( $user_id, XenForo::XF_USER_ID, $response['user']['user_id'] );
}
}
public function sync_user_profile( int $user_id, \WP_User $old_values ): void {
$xf_user_id = absint( get_the_author_meta( XenForo::XF_USER_ID, $user_id ) );
if( $xf_user_id == 0 ) {
$this->create_xenforo_account_from_wp_user($user_id);
return;
}
$update_user_array = [];
// Username change
if( !empty( $_POST['display_name'] ) && $_POST['display_name'] !== $old_values->display_name ){
$update_user_array['username'] = sanitize_text_field( $_POST['display_name'] );
$update_user_array['username_change_visible'] = true;
}
// Role change
if( !empty( $_POST['role'] ) && !in_array( $_POST['role'], $old_values->roles ) )
$update_user_array = array_merge( $update_user_array, self::prepare_sync_user_role( sanitize_text_field( $_POST['role'] ), $user_id ) );
// E-Mail change
if( !empty( $_POST['email'] ) && $_POST['email'] !== $old_values->user_email )
$update_user_array['email'] = sanitize_email( $_POST['email'] );
// Password change
if( !empty( $_POST['pass1'] ) && !empty( $_POST['pass2'] ) && $_POST['pass1'] === $_POST['pass2'] )
$update_user_array['password'] = esc_attr( $_POST['pass1'] );
$update_user_array['custom_fields']['wp_user_id'] = $user_id;
// ...
$response = null;
if( $update_user_array != [] )
$response = API::post__users_id( $xf_user_id, $update_user_array );
}
public function get_number_of_unviewed_alerts( int $user_id ): int {
$xf_user_id = absint( get_user_meta( $user_id, XenForo::XF_USER_ID, true ) ?? 0 );
if( $xf_user_id == 0 )
return 0;
$response = API::get__plaza_alerts_id( $xf_user_id );
if( $response != null )
return count( $response['alerts'] ?? [] );
return 0;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace RomhackPlaza\Extenders\XenForo;
use RomhackPlaza\Extenders\Abstract_Extender;
defined( 'ABSPATH' ) || exit;
class XenForo extends Abstract_Extender {
const string XF_USER_ID = "xf_user_id";
public User_Settings $users;
public Auth $auth;
public static function api_works(): bool {
$response = API::get__index();
if( $response == null )
return false;
if( !isset( $response['key']['type'] ) )
return false;
if( $response['key']['type'] !== 'super' )
return false;
return true;
}
/**
* Enable these three settings in .env file to enable XenForo linking.
* @return bool
*/
protected function can_extend(): bool
{
return $_ENV['ROMHACKPLAZA_XF_URL'] && $_ENV['ROMHACKPLAZA_XF_SUPER_USER_API_KEY'] && $_ENV['ROMHACKPLAZA_XF_SUPER_USER_ACCOUNT_ID'];
}
protected function extend(): void {
$this->users = new User_Settings();
$this->auth = new Auth();
do_action( "qm/debug", $this->users->get_number_of_unviewed_alerts( 1 ) );
}
}

46
src/Identifier.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace RomhackPlaza;
defined( 'ABSPATH' ) || exit;
class Identifier {
const string CAP_EDIT_POSTS = "edit_posts";
const string CAP_EDIT_OTHER_POSTS = "edit_others_posts";
const string CPT_TRANSLATIONS = "translations";
const string CPT_ROMHACKS = "romhacks";
const string CPT_HOMEBREWS = "homebrew";
const string CPT_UTILITIES = "utilities";
const string CPT_DOCUMENTS = "documents";
const string CPT_LUA_SCRIPTS = "lua-scripts";
const string CPT_TUTORIALS = "tutorials";
const string CPT_NEWS = "news";
const string CPT_REVIEWS = "reviews";
// ...
const string TAX_GAME = "game";
const string TAX_STATUS = "hack-status";
const string TAX_LANG = "language";
const string TAX_PLATFORM = "platform";
const string TAX_AUTHORS = "author-name";
const string TAX_UTILITY_CATEGORY = "utility-category";
const string TAX_LEVEL = "experience-level";
const string TAX_OS = "utility-os";
const string TAX_DOCUMENT_CATEGORY = "document-category";
const string TAX_MODIFICATIONS = "modifications";
const string TAX_HOMEBREW_TYPE = "homebrew-type";
const string TAX_NEWS_CATEGORY = "news-category";
const string TAX_GENRE = "genre";
const string TAX_TUTORIAL_CATEGORY = "tutorial-category";
const string TAX_LUA_MODIFICATIONS = "lua-modifications";
// ...
const string ENTRY_GALLERY = "my_gallery";
const string ENTRY_MAIN_IMAGE = "custom_featured_image";
private function __construct() {}
}

View File

@@ -91,7 +91,7 @@ class Capabilities extends Abstract_Overrider {
if( empty( $user_roles ) )
return null;
return $user_roles |> array_key_first(...)
return $user_roles |> array_first(...)
|> Roles::tryFrom(...);
}

View File

@@ -83,6 +83,8 @@ class Plugin {
$this->children['extend_save_post'] = new Extenders\Post\Save_Post();
$this->children['extend_post_properties'] = new Extenders\Post\Properties();
$this->children['extend_xenforo'] = new Extenders\XenForo\XenForo();
$this->children['extend_acf_pro'] = new Extenders\Advanced_Custom_Fields_Pro();
$this->children['override_post_announcements'] = new Overrides\Post_Announcements();

View File

@@ -30,6 +30,7 @@ class Settings {
'public_edit_disabled_tag_id' => '',
'discord_webhook_romhacks_translations' => '',
'discord_webhook_global' => '',
'xf_auto_create_user' => '0',
];
$this->refresh();

3
ts/globals.d.ts vendored
View File

@@ -2,5 +2,8 @@
declare const $: any;
declare const jQuery: any;
/// <reference types="wordpress" />
declare const wp: any;
/// <reference types="romhackplaza" />
declare function romhackplaza_manage_modal( a: object, b: string|undefined, c: string|undefined, d: string|undefined, e: string|undefined ): void;

View File

@@ -28,11 +28,11 @@ export class DropContainer {
private addEvents(): void {
this.element.addEventListener( 'click', this.submitFile, false );
this.element.addEventListener( 'click', (e: Event) => { this.submitFile(e) }, false );
this.element.addEventListener( 'dragover', (e: Event) => e.preventDefault(), false );
this.element.addEventListener( 'dragenter', (e: Event) => this.element.classList.add( 'drag-active'), false );
this.element.addEventListener( 'dragleave', (e: Event) => this.element.classList.remove( 'drag-active'), false );
this.element.addEventListener( 'drop', this.dropElement );
this.element.addEventListener( 'drop', (e: Event) => { this.dropElement( e ); } );
}
submitFile( e: Event ): void {

View File

@@ -0,0 +1,18 @@
import {__, I } from "./globals";
declare const _romhackplaza_script_uploader: any;
declare const romhackplaza_modal_submissions: any;
export class Reserve_Post_ID {
/**
*
* @param callback - MUST BE FROM A Upload child.
*/
constructor( callback: () => any ){
if( I.is_edit ) {
callback();
return;
}
}
}

View File

@@ -1,9 +1,12 @@
import {__, FORBIDDEN_CARS, romhackplaza_modal_submissions, during_upload, I } from "./globals";
import {__, FORBIDDEN_CARS, I } from "./globals";
import {DropContainer} from "./class-drop-container";
declare const _romhackplaza_script_uploader: any;
declare const romhackplaza_modal_submissions: any;
export class Upload {
file: File;
progress_bar: HTMLElement|undefined;
constructor( file: File ){
@@ -22,10 +25,20 @@ export class Upload {
console.error("WTF at beginUpload method.");
return;
}
this.switchDuringUpload();
if( typeof I.drop_container !== 'undefined' && I.drop_container instanceof DropContainer )
I.drop_container.switch();
let progress = document.getElementById( 'progress' );
if( progress !== null ) {
progress.style.display = 'block';
this.progress_bar = progress.querySelector('.bar') as HTMLElement;
}
if( I.reserved_post_id === undefined || I.reserved_post_id === null )
console.log( "ok" );
}
checkForbiddenCars() :boolean {
@@ -42,11 +55,20 @@ export class Upload {
private switchDuringUpload(): void {
// @ts-ignore
during_upload = !during_upload;
I.during_upload = !I.during_upload;
this.changeStatus( __( "Preparing upload...", 'romhackplaza' ) );
this.switchSubmissionButton();
}
private changeStatus( str: string ){
let sts: HTMLElement|null = document.getElementById( 'status' );
if( sts !== null )
sts.textContent = str;
}
private switchSubmissionButton(): void {
let btn: HTMLElement|null = document.getElementById( 'submitTranslationButton' );

View File

@@ -0,0 +1,52 @@
export class Uploader_Data {
private data: object;
constructor() {
this.data = {};
return new Proxy( this, {
get: ( target, p ) => {
return target.get( p as string );
},
set: ( target, p, v ) => {
target.set( p as string, v );
return true
}
});
}
add_field( field_name: string, field_value: any, readonly: boolean ){
// @ts-ignore
if( !this.data[field_name] )
// @ts-ignore
this.data[field_name] = { "data": field_value, can_write: ( readonly ? 1 : -1 ) };
}
get( field_name : string ): any {
// @ts-ignore
if( this.data[field_name] )
// @ts-ignore
return this.data[field_name]['data'];
return undefined;
}
set( field_name: string, field_value: any ){
// @ts-ignore
if( this.data[field_name] && this.data[field_name]['can_write'] != 0 ){
// @ts-ignore
this.data[field_name]['data'] = field_value;
// @ts-ignore
if( this.data[field_name]['can_write'] == 1 )
// @ts-ignore
this.data[field_name]['can_write'] = 0;
}
}
}

View File

@@ -1,18 +1,17 @@
export declare const _romhackplaza_script_uploader: any;
export declare const romhackplaza_modal_submissions: any;
declare const wp: any;
export const { __, _x, _n, _nx } = wp.i18n;
import {Uploader_Data} from "./class-uploader-data";
export const { __, _x, _n, _nx } = wp.i18n;
export const FORBIDDEN_CARS: Array<string> = [
"&", "+"
];
export let during_upload = false;
export const I: any = {
upload: undefined,
drop_container: undefined,
}
export const I : Uploader_Data = new Uploader_Data();
I.add_field( 'is_edit', false, true );
I.add_field( 'reserved_post_id', undefined, false );
I.add_field( 'during_upload', false, false );
I.add_field( 'upload', undefined, false );
I.add_field( 'drop_container', undefined, true );
export interface Can_Upload_Detail {
file: File;

View File

@@ -1,10 +1,16 @@
import {type Can_Upload_Detail, during_upload, I} from "./globals";
import {type Can_Upload_Detail, I} from "./globals";
import {Upload} from "./class-upload";
import {DropContainer} from "./class-drop-container";
document.addEventListener('DOMContentLoaded', () => {
if( !document.getElementById( 'fileInput' ) ) // Check if exists.
if( !document.getElementById( 'file-container' ) ) // Check if exists.
return;
// Add PostID if edit.
let url_params = new URLSearchParams(window.location.search);
I.is_edit = url_params.has( 'edit_entry' );
I.reserved_post_id = url_params.get('edit_entry' ) || null;
// @ts-ignore
I.drop_container = new DropContainer( document.getElementById( 'file-container' ) as HTMLElement, document.getElementById( 'file-container-text' ) as HTMLElement );
@@ -13,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener( 'can_upload', (e: CustomEvent<Can_Upload_Detail> ) => {
const { file } = e.detail;
if( !during_upload ) // @ts-ignore
if( !I.during_upload ) // @ts-ignore
I.upload = new Upload( file );
});

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
root: path.resolve(__dirname, '..'),
build: {
outDir: 'assets/js/submissions',
emptyOutDir: false,
rollupOptions: {
input: 'ts/submissions/index.ts',
output: {
entryFileNames: `uploader.js`,
},
},
},
});