GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Completed
Pull Request — develop (#42)
by Chris
01:59
created

namespace.php ➔ bgg_search()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 2
dl 0
loc 11
ccs 7
cts 7
cp 1
crap 2
rs 9.9
c 0
b 0
f 0
1
<?php
2
/**
3
 * Games Collector BoardGameGeek API integration.
4
 *
5
 * Integrates BoardGameGeek's XML API into Games Collector to allow game data to be imported.
6
 *
7
 * @package GC\GamesCollector\BGG
8
 * @since   1.2.0
9
 */
10
11
namespace GC\GamesCollector\BGG;
12
13
/**
14
 * Return the BGG v1 API endpoint.
15
 *
16
 * @since  1.2.0
17
 * @return string The BGG v1 endpoint.
18
 */
19
function bgg_api() {
20 1
	return esc_url( 'https://www.boardgamegeek.com/xmlapi/' );
21
}
22
23
/**
24
 * Return the BGG v2 (beta) API endpoint.
25
 *
26
 * @since  1.2.0
27
 * @return string The BGG v2 endpoint.
28
 */
29
function bgg_api2() {
30 1
	return esc_url( 'https://www.boardgamegeek.com/xmlapi2/' );
31
}
32
33
/**
34
 * Return the BGG search endpoint for a particular query.
35
 *
36
 * @since  1.2.0
37
 * @param  string $query The search query.
38
 * @param  string $type  The type of search (optional). Allowed values are rpgitem, videogame, boardgame, boardgameaccessory or boardgameexpansion.
39
 * @return string        The BGG search API URL.
40
 */
41
function bgg_search( string $query, $type = 'boardgame' ) {
42 1
	$query = str_replace( ' ', '+', $query );
43 1
	$type  = in_array( $type, [ 'rpgitem', 'videogame', 'boardgame', 'boardgameaccessory', 'boardgameexpansion' ] ) ? $type : 'boardgame';
44
45 1
	return esc_url_raw( sprintf(
46 1
		'%1$ssearch?search=%2$s&type=%3$s',
47 1
		bgg_api(),
48 1
		esc_html( $query ),
49 1
		esc_html( $type )
50
	) );
51
}
52
53
/**
54
 * Return the BGG API endpoint for a single game/entity.
55
 *
56
 * @since  1.2.0
57
 * @param  int $id The BGG entity ID.
58
 * @return string  The BGG URL.
59
 */
60
function bgg_game( int $id ) {
61 1
	return esc_url( bgg_api2() . 'thing?id=' . $id );
62
}
63
64
/**
65
 * Return the search results for a given query.
66
 *
67
 * @since  1.2.0
68
 * @param  string $query A search query for a game.
69
 * @return array         An array of possible matches.
70
 */
71
function get_bgg_search_results( $query ) {
72 1
	$response = wp_remote_get( bgg_search( $query ) );
73 1
	$results  = [];
74
75 1
	if ( isset( $response['response'] ) && 200 === $response['response']['code'] ) {
76 1
		$xml  = simplexml_load_string( wp_remote_retrieve_body( $response ) );
77
78 1
		if ( isset( $xml->boardgame ) ) {
79 1
			foreach ( $xml->boardgame as $game ) {
80 1
				$game = (array) $game;
81
82 1
				$results[] = [
83 1
					'id'   => ( isset( $game['@attributes']['objectid'] ) ) ? (int) $game['@attributes']['objectid'] : '',
84 1
					'name' => ( isset( $game['name'] ) ) ? $game['name'] : '',
85 1
					'year' => ( isset( $game['yearpublished'] ) ) ? $game['yearpublished'] : '',
86
				];
87
			}
88
		}
89
	}
90
91 1
	return $results;
92
}
93
94
/**
95
 * Return the BGG data that maps to data used in Games Collector for a game.
96
 *
97
 * @since  1.2.0
98
 * @param  int $id The BGG game id.
99
 * @return array   An array of game information pulled from the entry on Board Game Geek.
100
 */
101
function get_bgg_game( $id ) {
102 1
	$response = wp_remote_get( bgg_game( $id ) );
103 1
	$data     = [];
104
105 1
	if ( isset( $response['response'] ) && 200 === $response['response']['code'] ) {
106 1
		$xml = simplexml_load_string( wp_remote_retrieve_body( $response ) );
107 1
		$game = $xml->item;
0 ignored issues
show
Bug introduced by
The property item does not seem to exist in SimpleXMLElement.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
108
		$data = [
109 1
			'title'       => (string) $game->name->attributes()['value'],
110 1
			'image'       => (string) $game->image,
111 1
			'minplayers'  => (int) $game->minplayers->attributes()['value'],
112 1
			'maxplayers'  => (int) $game->maxplayers->attributes()['value'],
113 1
			'minplaytime' => (int) $game->minplaytime->attributes()['value'],
114 1
			'maxplaytime' => (int) $game->maxplaytime->attributes()['value'],
115 1
			'minage'      => (int) $game->minage->attributes()['value'],
116
			'categories'  => [],
117
		];
118
119 1
		$categories = [];
120
121 1
		foreach ( $game->link as $metadata ) {
122 1
			if ( 'boardgamecategory' === (string) $metadata->attributes()['type'] ) {
123 1
				$categories[] = (string) $metadata->attributes()['value'];
124
			}
125
		}
126
127 1
		$data['categories'] = ! empty( $categories ) ? $categories : [];
128
	}
129
130 1
	return $data;
131
}
132
133
/**
134
 * CMB2 field for BGG Search.
135
 *
136
 * @since 1.2.0
137
 */
138
function fields() {
0 ignored issues
show
Coding Style introduced by
fields uses the super-global variable $_GET which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
139
	$search_results = get_transient( 'gc_last_bgg_search' );
140
141
	// First run.
142
	if ( ! $search_results || isset( $_GET['reset_search'] ) ) {
143
144
		// If we're clearing the search, delete the transient and start over.
145
		if ( isset( $_GET['bgg_search_reset_nonce'] ) &&
146
			wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['bgg_search_reset_nonce'] ) ), 'bgg_search_reset_nonce' ) ) {
147
			delete_transient( 'gc_last_bgg_search' );
148
			add_action( 'admin_notices', __NAMESPACE__ . '\\search_cleared_notice' );
149
		}
150
151
		$cmb = new_cmb2_box( [
152
			'id'           => 'bgg-search',
153
			'title'        => __( 'Add game from Board Game Geek', 'games-collector' ),
154
			'object_types' => [ 'options-page' ],
155
			'option_key'   => 'add_from_bgg',
156
			'parent_slug'  => 'edit.php?post_type=gc_game',
157
			'menu_title'   => __( 'Add New From BGG', 'games-collector' ),
158
			'save_button'  => __( 'Search for Game', 'games-collector' ),
159
		] );
160
161
		$cmb->add_field( [
162
			'name'       => __( 'Search', 'games-collector' ),
163
			'id'         => 'bgg_searchform',
164
			'type'       => 'bgg_search',
165
			'desc'       => __( 'Type in the title of a game to search for that game on Board Game Geek.', 'games-collector' ),
166
		] );
167
	} else {
168
		// Choose the right game.
169
		$cmb = new_cmb2_box( [
170
			'id'           => 'bgg-search-2',
171
			'title'        => __( 'Add game from Board Game Geek &mdash; Step 2', 'games-collector' ),
172
			'object_types' => [ 'options-page' ],
173
			'option_key'   => 'add_from_bgg',
174
			'parent_slug'  => 'edit.php?post_type=gc_game',
175
			'menu_title'   => __( 'Add New From BGG', 'games-collector' ),
176
			'save_button'  => __( 'Add Game', 'games-collector' ),
177
		] );
178
179
		$cmb->add_field( [
180
			'name'       => __( 'Search Results', 'games-collector' ),
181
			'id'         => 'bgg_search_results',
182
			'type'       => 'radio',
183
			'desc'       => __( 'Select the game that matches your search.', 'games-collector' ),
184
			'options'    => bgg_search_results_options( $search_results ),
185
		] );
186
187
		$cmb->add_field( [
188
			'id'         => 'bgg_search_results_hidden',
189
			'type'       => 'hidden',
190
			'attributes' => [
191
				'name'  => 'action',
192
				'value' => 'bgg_insert_game',
193
			],
194
		] );
195
196
		$cmb->add_field( [
197
			'id'          => 'bgg_search_reset',
198
			'type'        => 'bgg_search_reset',
199
			'button_name' => __( 'Reset Search', 'games-collector' ),
200
		] );
201
	}
202
}
203
204
/**
205
 * Render the BGG search field in CMB2.
206
 *
207
 * @since  1.2.0
208
 * @param  string $field             Not used.
209
 * @param  string $escaped_value     Not used.
210
 * @param  int    $object_id         Not used.
211
 * @param  string $object_type       Not used.
212
 * @param  object $field_type_object The CMB2 field type object.
213
 */
214
function render_cmb2_bgg_search( $field, $escaped_value, $object_id, $object_type, $field_type_object ) {
0 ignored issues
show
Unused Code introduced by
The parameter $field is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $escaped_value is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $object_id is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $object_type is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
215
	$description = '<p class="description">' . esc_html( $field_type_object->field->args()['desc'] ) . '</p>';
216
	$form = sprintf( '<input id="%1$s" class="regular-text" name="%2$s" value="" placeholder="%3$s" type="text">',
217
		esc_attr( $field_type_object->field->args()['id'] ),
218
		esc_attr( $field_type_object->field->args()['id'] ),
219
		__( 'A game title or search, e.g. &ldquo;betrayal house hill&rdquo;', 'usat' )
220
	);
221
	$hidden = '<input type="hidden" name="action" value="bgg_search_response">';
222
	$output = $hidden . $form . $description;
223
224
	echo wp_kses( $output, [
225
		'p'     => [
226
			'class'       => [],
227
		],
228
		'input' => [
229
			'id'          => [],
230
			'class'       => [],
231
			'name'        => [],
232
			'value'       => [],
233
			'type'        => [],
234
			'placeholder' => [],
235
		],
236
	] );
237
}
238
239
/**
240
 * Render the bgg_search_reset field in CMB2.
241
 *
242
 * This adds a link styled like a button which will clear out the current BGG game search.
243
 *
244
 * @since  1.2.0
245
 * @param  string $field             Not used.
246
 * @param  string $escaped_value     Not used.
247
 * @param  int    $object_id         Not used.
248
 * @param  string $object_type       Not used.
249
 * @param  object $field_type_object The CMB2 field object.
250
 */
251
function render_cmb2_bgg_search_reset( $field, $escaped_value, $object_id, $object_type, $field_type_object ) {
0 ignored issues
show
Unused Code introduced by
The parameter $field is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $escaped_value is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $object_id is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $object_type is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
252
253
	$nonce = wp_create_nonce( 'bgg_search_reset_nonce' );
254
	$url   = add_query_arg( [
255
		'post_type'              => 'gc_game',
256
		'page'                   => 'add_from_bgg',
257
		'reset_search'           => true,
258
		'bgg_search_reset_nonce' => $nonce,
259
	], admin_url( 'edit.php' ) );
260
261
	ob_start();
262
	?>
263
	<a href="<?php echo esc_url_raw( $url ); ?>">
264
		<div class="button alignright" name="bgg_search_reset">
265
			<?php echo esc_html( $field_type_object->field->args['button_name'] ); ?>
266
		</div>
267
	</a>
268
	<input type="hidden" name="action" value="bgg_search_reset" />
269
	<?php
270
271
	echo wp_kses( ob_get_clean(), [
272
		'a' => [
273
			'href' => [],
274
		],
275
		'div' => [
276
			'class' => [],
277
		],
278
		'input' => [
279
			'type'  => [],
280
			'name'  => [],
281
			'value' => [],
282
		],
283
	]);
284
}
285
286
/**
287
 * Store the Board Game Geek search results in a transient so we can access it later.
288
 *
289
 * @since  1.2.0
290
 * @return void|wp_die
291
 */
292
function search_response() {
293
	if ( isset( $_POST['nonce_CMB2phpbgg-search'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce_CMB2phpbgg-search'] ) ), 'nonce_CMB2phpbgg-search' ) ) {
294
295
		$search_query = isset( $_POST['bgg_searchform'] ) ? sanitize_text_field( wp_unslash( $_POST['bgg_searchform'] ) ) : '';
296
		$results      = get_bgg_search_results( $search_query );
297
		set_transient( 'gc_last_bgg_search', $results, HOUR_IN_SECONDS );
298
		wp_safe_redirect( admin_url( 'edit.php?post_type=gc_game&page=add_from_bgg&step=2' ) );
299
		return;
300
	}
301
302
	return wp_die( esc_html__( 'Security check failed. What were you doing?', 'games-collector' ), esc_html__( 'Nonce check failed', 'games-collector' ) );
303
}
304
305
/**
306
 * Display a notice when the search was cleared.
307
 *
308
 * @since 1.2.0
309
 */
310
function search_cleared_notice() {
311
	?>
312
	<div class="notice updated">
313
		<p>
314
			<?php esc_html_e( 'Board Game Geek game search reset.', 'games-collector' ); ?>
315
		</p>
316
	</div>
317
	<?php
318
}
319
320
/**
321
 * Rearrange the Games Collector submenu.
322
 *
323
 * This moves the Add New from BGG link to directly below Add New.
324
 *
325
 * @since  1.2.0
326
 * @param  bool $menu_order Returns true if successful.
327
 * @return bool             Returns the $menu_order unchanged.
328
 */
329
function submenu_order( $menu_order ) {
330
	global $submenu;
0 ignored issues
show
Compatibility Best Practice introduced by
Use of global functionality is not recommended; it makes your code harder to test, and less reusable.

Instead of relying on global state, we recommend one of these alternatives:

1. Pass all data via parameters

function myFunction($a, $b) {
    // Do something
}

2. Create a class that maintains your state

class MyClass {
    private $a;
    private $b;

    public function __construct($a, $b) {
        $this->a = $a;
        $this->b = $b;
    }

    public function myFunction() {
        // Do something
    }
}
Loading history...
331
332
	// Store the Games Collector menu to a variable.
333
	$items = $submenu['edit.php?post_type=gc_game'];
334
335
	// Item 11 is right after Add New. Item 16 is the link for Add New from BGG.
336
	$submenu['edit.php?post_type=gc_game'][11] = $items[16]; // WPCS: override ok.
337
	// Remove item 16, the old Add New from BGG link.
338
	unset( $submenu['edit.php?post_type=gc_game'][16] );
339
	// Re-sort the menu by index.
340
	ksort( $submenu['edit.php?post_type=gc_game'] );
341
342
	return $menu_order;
343
}
344
345
/**
346
 * Dislplay the BGG search results in an option array for CMB2.
347
 *
348
 * @since  1.2.0
349
 * @param  array $results The array of BGG search results.
350
 * @return array          An array of options for CMB2.
351
 */
352
function bgg_search_results_options( $results ) {
353 1
	$options = [];
354 1
	foreach ( $results as $game ) {
355 1
		$options[ absint( $game['id'] ) ] = sprintf( '%1$s [%2$s] (%3$s)',
356 1
			'<strong>' . esc_html( $game['name'] ) . '</strong>',
357 1
			esc_html( $game['year'] ),
358 1
			esc_html( $game['id'] )
359
		);
360
	}
361
362 1
	return $options;
363
}
364
365
/**
366
 * Insert the game using BGG data from the API.
367
 *
368
 * @since  1.2.0
369
 * @return void|wp_die
370
 */
371
function insert_game() {
372 1
	if ( isset( $_POST['nonce_CMB2phpbgg-search-2'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce_CMB2phpbgg-search-2'] ) ), 'nonce_CMB2phpbgg-search-2' ) ) {
373
374 1
		$game_id      = isset( $_POST['bgg_search_results'] ) ? absint( wp_unslash( $_POST['bgg_search_results'] ) ) : false;
375 1
		$redirect_url = admin_url( 'edit.php?post_type=gc_game&page=add_from_bgg' );
376
377 1
		if ( $game_id ) {
378 1
			$game = get_bgg_game( $game_id );
379
380
			// Check if game already exists.
381 1
			if ( get_page_by_title( $game['title'], OBJECT, 'gc_game' ) ) {
382
				return wp_die(
383
					esc_html__( 'A game with that title already exists. Please try again.', 'games-collector' ),
384
					esc_html__( 'Duplicate game found', 'games-collector' ),
385
					[ 'back_link' => true ]
386
				);
387
			}
388
389 1
			$post_id = wp_insert_post( [
390 1
				'post_type'   => 'gc_game',
391 1
				'post_title'  => esc_html( $game['title'] ),
392 1
				'post_status' => 'draft',
393
			] );
394
395 1
			if ( ! is_wp_error( $post_id ) ) {
396 1
				$redirect_url = admin_url( sprintf( 'post.php?post=%d&action=edit', $post_id ) );
397
398
				// Add game meta.
399 1
				add_post_meta( $post_id, '_gc_min_players', absint( $game['minplayers'] ) );
400 1
				add_post_meta( $post_id, '_gc_max_players', absint( $game['maxplayers'] ) );
401 1
				add_post_meta( $post_id, '_gc_age', absint( $game['minage'] ) );
402 1
				add_post_meta( $post_id, '_gc_link', sprintf( 'https://www.boardgamegeek.com/boardgame/%d/', $game_id ) );
403 1
				add_post_meta( $post_id, '_gc_bgg_id', $game_id );
404
405 1
				if ( absint( $game['minplaytime'] ) === absint( $game['maxplaytime'] ) ) {
406 1
					add_post_meta( $post_id, '_gc_time', esc_html( $game['minplaytime'] ) );
407
				} else {
408
					add_post_meta( $post_id, '_gc_time', esc_html( $game['minplaytime'] . '-' . $game['maxplaytime'] ) );
409
				}
410
411 1
				if ( isset( $game['categories'] ) ) {
412 1
					foreach ( $game['categories'] as $game_attribute ) {
413 1
						$similar_attribute = get_attribute_like( $game_attribute );
414
415
						// If there's an existing attribute that matches the BGG category, use that.
416 1
						if ( $similar_attribute ) {
417
							wp_set_post_terms( $post_id, [ $similar_attribute ], 'gc_attribute', true );
418
						}
419
420
						// Otherwise insert a new term.
421 1
						wp_set_post_terms( $post_id, $game_attribute, 'gc_attribute', true );
422
					}
423
				}
424
425
				// Sideload the image from BGG.
426 1
				attach_bgg_image( $post_id, $game );
427
			}
428
		}
429
430
		// Delete the transient so we can do this again.
431 1
		delete_transient( 'gc_last_bgg_search' );
432
433
		// Redirect to the edit page for this game.
434 1
		if ( is_user_logged_in() ) {
435
			wp_safe_redirect( esc_url_raw( $redirect_url ) );
436
		}
437
438 1
		return;
439
	}
440
441
	return wp_die( esc_html__( 'Security check failed. What were you doing?', 'games-collector' ), esc_html__( 'Nonce check failed', 'games-collector' ) );
442
}
443
444
/**
445
 * Check if an existing game attribute term exists and return the ID if it does.
446
 *
447
 * @since  1.2.0
448
 * @param  string $search The game attribute name.
449
 * @return int|bool       The term ID if a matching term exists, false if it doesn't.
450
 */
451
function get_attribute_like( $search ) {
452
	// Check if a previously cached attribute for this term exists already.
453 1
	$cached_term_search = get_transient( 'gc_frequently_used_attributes' );
454 1
	if ( $cached_term_search && array_key_exists( $search, $cached_term_search ) ) {
455
		return $cached_term_search[ $search ];
456
	}
457
458 1
	$terms     = [];
459 1
	$all_terms = get_terms( [
460 1
		'taxonomy' => 'gc_attribute',
461
		'hide_empty' => false,
462
	] );
463
464 1
	foreach ( $all_terms as $term ) {
465 1
		similar_text( $term->name, $search, $similarity );
466 1
		if ( $similarity > 75 ) {
467 1
			$terms[] = $term->term_id;
468
		}
469
	}
470
471
472 1
	if ( ! is_wp_error( $terms ) && count( $terms ) > 0 ) {
473
		// Cache this term combination so we can access it faster later.
474 1
		if ( ! $cached_term_search ) {
475 1
			set_transient( 'gc_frequently_used_attributes', [
476 1
				$search => $terms[0],
477 1
			], 999 * YEAR_IN_SECONDS );
478
		} else {
479
			$cached_term_search = array_merge( $cached_term_search, [ $search => $terms[0] ] );
480
			set_transient( 'gc_frequently_used_attributes', $cached_term_search, 999 * YEAR_IN_SECONDS );
481
		}
482
483 1
		return $terms[0];
484
	}
485
486
	return false;
487
}
488
489
/**
490
 * Sideload image for a BGG image.
491
 *
492
 * @since  1.2.0
493
 * @param  int   $post_id The game ID.
494
 * @param  array $game    The array of game data from BGG.
495
 */
496
function attach_bgg_image( $post_id, $game ) {
497 1
	$image_id = media_sideload_image( esc_url_raw( $game['image'] ), $post_id, esc_html( $game['title'] ), 'id' );
498 1
	set_post_thumbnail( $post_id, $image_id );
499
}
500