diff --git a/wp-content/plugins/embl-taxonomy/README.md b/wp-content/plugins/embl-taxonomy/README.md index 7625a9f75..65483db30 100644 --- a/wp-content/plugins/embl-taxonomy/README.md +++ b/wp-content/plugins/embl-taxonomy/README.md @@ -115,13 +115,9 @@ A list of taxonomy terms can be viewed by visiting **Posts > EMBL Taxonomy**: /wp-admin/edit-tags.php?taxonomy=embl_taxonomy ``` -If the cached taxonomy is older than `MAX_AGE` an admin notice will appear linking to: +If the cached taxonomy is older than `MAX_AGE` an admin notice will appear with a button to resync the taxonomy. The button opens a modal that handles the sync process. Syncing is done in batches using mutliple API requests to avoid a timeout. -``` -/wp-admin/edit-tags.php?taxonomy=embl_taxonomy&sync=true -``` - -This URL will force a resync with the EMBL Taxonomy. +The `SYNC_MAX_TERMS` constant in [register.php](/wp-content/plugins/embl-taxonomy/includes/register.php) defines the batch size. Lowering this value will slow down the process by using shorter requests. ## ACF Configuration diff --git a/wp-content/plugins/embl-taxonomy/acf-json/embl-taxonomy-terms.json b/wp-content/plugins/embl-taxonomy/acf-json/embl-taxonomy-terms.json index 8abf55579..c32a93875 100644 --- a/wp-content/plugins/embl-taxonomy/acf-json/embl-taxonomy-terms.json +++ b/wp-content/plugins/embl-taxonomy/acf-json/embl-taxonomy-terms.json @@ -6,67 +6,76 @@ "key": "field_embl_taxonomy_term_who", "label": "Who", "name": "embl_taxonomy_term_who", + "aria-label": "", "type": "taxonomy", "instructions": "", "required": 0, "conditional_logic": 0, "wrapper": { - "width": "33", + "width": "", "class": "", "id": "" }, "taxonomy": "embl_taxonomy", - "field_type": "select", - "allow_null": 1, "add_term": 0, "save_terms": 0, "load_terms": 0, "return_format": "id", - "multiple": 0 + "field_type": "select", + "allow_null": 1, + "bidirectional": 0, + "multiple": 0, + "bidirectional_target": [] }, { "key": "field_embl_taxonomy_term_what", "label": "What", "name": "embl_taxonomy_term_what", + "aria-label": "", "type": "taxonomy", "instructions": "", "required": 0, "conditional_logic": 0, "wrapper": { - "width": "33", + "width": "", "class": "", "id": "" }, "taxonomy": "embl_taxonomy", - "field_type": "select", - "allow_null": 1, "add_term": 0, "save_terms": 0, "load_terms": 0, "return_format": "id", - "multiple": 0 + "field_type": "select", + "allow_null": 1, + "bidirectional": 0, + "multiple": 0, + "bidirectional_target": [] }, { "key": "field_embl_taxonomy_term_where", "label": "Where", "name": "embl_taxonomy_term_where", + "aria-label": "", "type": "taxonomy", "instructions": "", "required": 0, "conditional_logic": 0, "wrapper": { - "width": "33", + "width": "", "class": "", "id": "" }, "taxonomy": "embl_taxonomy", - "field_type": "select", - "allow_null": 1, "add_term": 0, "save_terms": 0, "load_terms": 0, "return_format": "id", - "multiple": 0 + "field_type": "select", + "allow_null": 1, + "bidirectional": 0, + "multiple": 0, + "bidirectional_target": [] } ], "location": [ @@ -93,5 +102,6 @@ "hide_on_screen": "", "active": true, "description": "", - "modified": 1636713925 + "show_in_rest": 0, + "modified": 1697465104 } diff --git a/wp-content/plugins/embl-taxonomy/assets/embl-taxonomy.js b/wp-content/plugins/embl-taxonomy/assets/embl-taxonomy.js new file mode 100644 index 000000000..7bbf91a0d --- /dev/null +++ b/wp-content/plugins/embl-taxonomy/assets/embl-taxonomy.js @@ -0,0 +1,96 @@ +(() => { + // Bail if no sync button + $button = document.querySelector('#embl-taxonomy-sync'); + if (!$button) return; + + // Append styles for modal + const $style = document.createElement('style'); + $style.id = 'embl-taxonomy-css'; + $style.innerHTML = ` +#embl-taxonomy-modal { + border: 0px; + border-radius: 5px; + box-shadow: + 0px 0.3px 1.4px rgba(0, 0, 0, 0.056), + 0px 0.7px 3.3px rgba(0, 0, 0, 0.081), + 0px 1.3px 6.3px rgba(0, 0, 0, 0.1), + 0px 2.2px 11.2px rgba(0, 0, 0, 0.119), + 0px 4.2px 20.9px rgba(0, 0, 0, 0.144), + 0px 10px 50px rgba(0, 0, 0, 0.2) + ; + padding: 10px 20px; + text-align: center; +} +#embl-taxonomy-modal::backdrop { + background-color: rgb(0,0,0,0.2); + backdrop-filter: blur(2px); +} +#embl-taxonomy-modal .spinner { + float: none; + vertical-align: top; + margin: 0 10px 0 0; +} +`; + document.head.appendChild($style); + + // Get localized data from PHP + const {data, path, token, redirect} = window.emblTaxonomySettings; + + // Create modal for output + const $modal = document.createElement('dialog'); + $modal.id = 'embl-taxonomy-modal'; + $modal.style.display = 'none'; + $modal.innerHTML = ` +

${data.syncing}

+

${data.reload}

+ +`; + document.body.appendChild($modal); + const $progress = $modal.querySelector('progress'); + + // Sync button handler + const startSync = async (url) => { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-WP-Nonce': token + } + }); + const json = await response.json(); + if (json.error) { + $modal.innerHTML = `

${json.error}

`; + return; + } + if (Object.hasOwn(json, 'total')) { + $progress.max = json.total; + } + if (Object.hasOwn(json, 'offset')) { + $progress.value = json.offset; + } + if (json.success === true) { + $progress.value = $progress.max; + window.location.href = redirect; + return; + } + if (json.next) { + startSync(new URL(json.next)); + return; + } + throw new Error(); + } catch { + $modal.innerHTML = `

${data.error}

`; + } + }; + + $button.addEventListener('click', (ev) => { + ev.preventDefault(); + $button.disabled = true; + $button.innerText = 'Syncing...'; + $progress.removeAttribute('max'); + $progress.removeAttribute('value'); + $modal.style.removeProperty('display'); + $modal.showModal(); + startSync(new URL(path)); + }); +})(); diff --git a/wp-content/plugins/embl-taxonomy/includes/register.php b/wp-content/plugins/embl-taxonomy/includes/register.php index 51eb8d258..22acc2cb7 100644 --- a/wp-content/plugins/embl-taxonomy/includes/register.php +++ b/wp-content/plugins/embl-taxonomy/includes/register.php @@ -4,8 +4,13 @@ if ( ! class_exists('EMBL_Taxonomy_Register') ) : + class EMBL_Taxonomy_Register { + // Maximum number of terms to sync per API request + // Set as high as possible but reduce if requests timeout + const SYNC_MAX_TERMS = 500; + // Options table keys const OPTION_MODIFIED = EMBL_Taxonomy::TAXONOMY_NAME . '_modified'; @@ -14,8 +19,6 @@ class EMBL_Taxonomy_Register { protected $labels; - private $sync_error = false; - public function __construct() { $this->labels = array( @@ -33,7 +36,6 @@ public function __construct() { add_filter(EMBL_Taxonomy::TAXONOMY_NAME . '_name', array($this, 'filter_term_name'), 10, 3); if (is_admin()) { - add_action('admin_init', array($this, 'action_admin_init')); add_action('admin_notices', array($this, 'action_admin_notices')); add_action('admin_enqueue_scripts', array($this, 'action_admin_enqueue')); } @@ -48,12 +50,8 @@ public function __construct() { */ add_filter( 'manage_edit-embl_taxonomy_columns' , 'wptc_embl_taxonomy_columns' ); function wptc_embl_taxonomy_columns( $columns ) { - // remove slug column - // unset($columns['slug']); - - // add column - $columns['embl_taxonomy_term_uuid'] = __('EMBL Term UUID'); - return $columns; + $columns['embl_taxonomy_term_uuid'] = __('EMBL Term UUID', 'embl'); + return $columns; } /* @@ -63,18 +61,18 @@ function wptc_embl_taxonomy_columns( $columns ) { */ add_filter( 'manage_embl_taxonomy_custom_column', 'wptc_embl_taxonomy_column_content', 10, 3 ); function wptc_embl_taxonomy_column_content( $content, $column_name, $term_id ) { - // get the term object - $term = get_term( $term_id, 'embl_taxonomy' ); - // check if column is our custom column - if ( 'embl_taxonomy_term_uuid' == $column_name ) { + // get the term object + $term = get_term( $term_id, 'embl_taxonomy' ); + // check if column is our custom column + if ( 'embl_taxonomy_term_uuid' == $column_name ) { $full_term = embl_taxonomy_get_term($term->term_id); // Eventually we should link back to the contenHub, however we don't currently // have a good way to search by UUID // https://dev.content.embl.org/api/v1/pattern.html?filter-content-type=profiles&filter-uuid=2a270b68-46c3-4b3f-92c5-0a65eb896c86&pattern=node-display-title // the above query depends on knowing the conent type - $content = ''.end($full_term->meta['embl_taxonomy_ids']).''; - } - return $content; + $content = ''.end($full_term->meta['embl_taxonomy_ids']).''; + } + return $content; } $this->set_read_only(); @@ -144,9 +142,21 @@ public function register_taxonomy() { /** * Hook for `rest_api_init` - * Add taxonomy terms assigned to posts */ public function register_rest_api() { + // Using `POST` because GET requests are easier to accidentally invoke + // API route will be called multiple times with the `offset` parameter + register_rest_route(EMBL_Taxonomy::TAXONOMY_NAME . '/v1', '/sync/', array( + 'methods' => 'POST', + 'callback' => array($this, 'sync_taxonomy'), + // Secure the API route + // Header token from `wp_localize_script` below is required + 'permission_callback' => function () { + return current_user_can('manage_categories'); + } + )); + + // Add taxonomy terms assigned to posts as field for WP REST API register_rest_field( EMBL_Taxonomy::TAXONOMY_TYPES, EMBL_Taxonomy::TAXONOMY_NAME . '_terms', @@ -208,59 +218,87 @@ static public function get_wp_taxonomy() { /** * Read and parse the EMBL Taxonomy API as JSON - * Error messages are picked up by the `admin_notices` action * The WordPress Taxonomy is rebuilt with matching ID terms synced */ - public function sync_taxonomy() { - // Make contenthub taxonomy api call to work on local - $whitelist = array( - '127.0.0.1', - '::1' - ); + public function sync_taxonomy(WP_REST_Request $request = null) { - if(in_array($_SERVER['REMOTE_ADDR'], $whitelist)) { - $embl_taxonomy_url = "https://content.embl.org/api/v1/pattern.json?pattern=embl-ontology&source=contenthub"; - } - else { - // Prod Proxy endpoint. - $embl_taxonomy_url = embl_taxonomy_get_url(); + $params = array(); + if ($request) { + $params = $request->get_query_params(); } + $offset = isset($params['offset']) ? intval($params['offset']) : -1; - // Attempt to read the EMBL Taxonomy API - $data = file_get_contents($embl_taxonomy_url); - if ($data === false) { - $this->sync_error = sprintf( - __('The %1$s API endpoint could not be accessed.', 'embl'), - $this->labels['name'] - ); - return $this->sync_error; - } + // Temporary file location to cache old and new data during sync process + $oldpath = trailingslashit( WP_CONTENT_DIR ) . 'uploads/' . EMBL_Taxonomy::TAXONOMY_NAME . '.old.txt'; + $newpath = trailingslashit( WP_CONTENT_DIR ) . 'uploads/' . EMBL_Taxonomy::TAXONOMY_NAME . '.new.txt'; - // Attempt to parse API results - $json_terms = self::decode_terms($data); - if (empty($json_terms)) { - $this->sync_error = sprintf( - __('The %1$s API result could not be parsed.', 'embl'), - $this->labels['name'] - ); - return $this->sync_error; + // Start new sync process if not offset is provided + if ($offset < 0) { + // Delete temporary files + if (file_exists($oldpath)) { + unlink($oldpath); + } + if (file_exists($newpath)) { + unlink($newpath); + } + // Get the existing WordPress taxonomy + file_put_contents($oldpath, serialize(self::get_wp_taxonomy())); + // Fetch the new terms + $value = $this->sync_taxonomy_temp_file($newpath); + // Handle errors + if (is_string($value)) { + if ($request) { + return array( + 'error' => $value + ); + } else { + throw new Exception($value); + } + } + // Return the URL for the first batch + if ($request) { + return array( + 'next' => rest_url(EMBL_Taxonomy::TAXONOMY_NAME .'/v1/sync/?offset=0'), + 'total' => $value, + 'offset' => 0 + ); + exit; + } } - // Generate the new taxonomy terms from the API terms provided - $new_terms = array(); - - self::generate_terms($json_terms, $new_terms); - - self::sort_terms($new_terms); - - // Get the existing WordPress taxonomy - $wp_taxonomy = self::get_wp_taxonomy(); + // Retrieve the old terms + $wp_taxonomy = unserialize(file_get_contents($oldpath)); + // Retrieve the new terms + $new_terms = unserialize(file_get_contents($newpath)); // Allow taxonomy terms to be inserted $this->set_read_only(false); + // Batch size for Request API or sync all terms (for WP CLI) + $max_terms = $request ? self::SYNC_MAX_TERMS : PHP_INT_MAX; + // Iterate over all terms to sync + $i = 0; foreach ($new_terms as $term) { + // Skip terms before the offset + if ($i < $offset) { + $i++; + continue; + } + // Check if batch is complete + if ($i >= $offset + $max_terms) { + $this->set_read_only(true); + // Cache progress and redirect to continue + file_put_contents($oldpath, serialize($wp_taxonomy)); + // Return the URL for the next batch + return array( + 'next' => rest_url(EMBL_Taxonomy::TAXONOMY_NAME . '/v1/sync/?offset=' . $i), + 'total' => count($new_terms), + 'offset' => $i, + ); + exit; + } + $i++; // Find existing `WP_Term` in the taxonomy based on META_IDS structure $wp_term = null; @@ -339,7 +377,61 @@ public function sync_taxonomy() { update_option(self::OPTION_MODIFIED, time()); - return true; + unlink($newpath); + unlink($oldpath); + + // Return success message + return array( + 'success' => true, + 'terms' => count($new_terms) + ); + } + + /** + * Request new taxonomy terms from the EMBL Taxonomy API + * This is done at the start of the sync process + */ + private function sync_taxonomy_temp_file($newpath) { + // Make contenthub taxonomy api call to work on local + $whitelist = array( + '127.0.0.1', + '::1' + ); + if(in_array($_SERVER['REMOTE_ADDR'], $whitelist)) { + $embl_taxonomy_url = "https://content.embl.org/api/v1/pattern.json?pattern=embl-ontology&source=contenthub"; + } else { + // Prod Proxy endpoint. + $embl_taxonomy_url = embl_taxonomy_get_url(); + } + + // Attempt to read the EMBL Taxonomy API + $data = file_get_contents($embl_taxonomy_url); + if ($data === false) { + return sprintf( + __('The %1$s API endpoint could not be accessed.', 'embl'), + $this->labels['name'] + ); + } + + // Attempt to parse API results + $json_terms = self::decode_terms($data); + if (empty($json_terms)) { + return sprintf( + __('The %1$s API result could not be parsed.', 'embl'), + $this->labels['name'] + ); + } + + // Generate the new taxonomy terms from the API terms provided + $new_terms = array(); + + self::generate_terms($json_terms, $new_terms); + + self::sort_terms($new_terms); + + file_put_contents($newpath, serialize($new_terms)); + + return count($new_terms); } /** @@ -457,46 +549,19 @@ static private function sort_terms(& $terms) { $blen = count($b[EMBL_Taxonomy::META_IDS]); // Sort alphabetically if same level if ($a[EMBL_Taxonomy::META_IDS][0] !== $b[EMBL_Taxonomy::META_IDS][0] || $alen === $blen) { - return $a['name'] > $b['name']; + return strcmp($a['name'], $b['name']); } // Sort by longest chain - return $alen > $blen; + return $alen - $blen; }); } - /** - * Action `admin_init` - */ - public function action_admin_init() { - if ( ! current_user_can('manage_categories')) { - return; - } - global $pagenow; - if ($pagenow !== 'edit-tags.php') { - return; - } - if (array_key_exists('taxonomy', $_GET) && $_GET['taxonomy'] !== EMBL_Taxonomy::TAXONOMY_NAME) { - return; - } - if (array_key_exists('sync', $_GET) && $_GET['sync'] === 'true') { - $this->sync_taxonomy(); - } - } - /** * Action `admin_notices` * Show admin warning notice if the taxonomy needs syncing * Show admin success notice if a sync happening recently */ public function action_admin_notices() { - - if ($this->sync_error) { - printf('

%2$s

', - esc_attr('notice notice-error'), - esc_html($this->sync_error) - ); - } - if ( ! current_user_can('manage_categories')) { return; } @@ -508,14 +573,14 @@ public function action_admin_notices() { // Sync required (all pages) if (($now - $modified) >= EMBL_Taxonomy::MAX_AGE) { - printf('

%2$s %3$s

', + printf('

%2$s %3$s

', esc_attr('notice notice-warning'), esc_html(sprintf( __('The %1$s may be outdated and should be synced', 'embl'), $this->labels['name'] )), sprintf( - '%2$s', + '', esc_attr('edit-tags.php?taxonomy=' . EMBL_Taxonomy::TAXONOMY_NAME . '&sync=true'), esc_html(__('Sync now', 'embl')) ) @@ -539,7 +604,13 @@ public function action_admin_notices() { if ( ! $notice && function_exists('get_current_screen')) { $screen = get_current_screen(); if ($screen->id === 'edit-embl_taxonomy') { - printf('

%2$s %3$s

', + if (isset($_GET['synced']) && $_GET['synced'] === 'true') { + printf('

%2$s

', + esc_attr('notice notice-success'), + __('Taxonomy sync complete.', 'embl') + ); + } + printf('

%2$s %3$s

', esc_attr('notice notice-info'), esc_html(sprintf( __('The %1$s was last synced at %2$s', 'embl'), @@ -547,7 +618,7 @@ public function action_admin_notices() { date('jS F Y H:i:s', $modified) )), sprintf( - '%2$s', + '', esc_attr('edit-tags.php?taxonomy=' . EMBL_Taxonomy::TAXONOMY_NAME . '&sync=true'), esc_html(__('Sync now', 'embl')) ) @@ -597,6 +668,28 @@ public function filter_term_name($value, $term_id, $context) { public function action_admin_enqueue() { $screen = get_current_screen(); $edit_tags_id = 'edit-' . EMBL_Taxonomy::TAXONOMY_NAME; + + wp_register_script( + 'embl-taxonomy', + plugin_dir_url(__FILE__) . '../assets/embl-taxonomy.js', + array(), + time(), + true + ); + + wp_localize_script('embl-taxonomy', 'emblTaxonomySettings', array( + 'data' => array( + 'syncing' => __('Syncing – please do not close this window.', 'embl'), + 'reload' => __('This page will reload after the sync is done.', 'embl'), + 'error' => __('There was an error syncing the taxonomy.', 'embl') + ), + 'redirect' => esc_url_raw(admin_url('edit-tags.php?taxonomy=' . EMBL_Taxonomy::TAXONOMY_NAME . '&synced=true')), + 'path' => esc_url_raw(rest_url(EMBL_Taxonomy::TAXONOMY_NAME . '/v1/sync')), + 'token' => wp_create_nonce('wp_rest') + )); + + wp_enqueue_script('embl-taxonomy'); + if ($screen->id === $edit_tags_id) { wp_enqueue_style( $edit_tags_id, diff --git a/wp-content/plugins/embl-taxonomy/includes/settings.php b/wp-content/plugins/embl-taxonomy/includes/settings.php index b73ae81a7..d3a93b3af 100644 --- a/wp-content/plugins/embl-taxonomy/includes/settings.php +++ b/wp-content/plugins/embl-taxonomy/includes/settings.php @@ -48,13 +48,12 @@ function __construct() { 'wp_head', array($this, 'wp_head') ); - - // foreach ($this->props as $prop) { - // add_filter( - // "acf/fields/taxonomy/query/name={$prop['acf']}", - // array($this, 'acf_query_terms'), 10, 3 - // ); - // } + foreach ($this->props as $prop) { + add_filter( + "acf/fields/taxonomy/query/name={$prop['acf']}", + array($this, 'acf_query_terms'), 10, 3 + ); + } } /** @@ -155,19 +154,33 @@ function get_field($name) { return $value; } - /** * Filter the ACF taxonomy query response so it's limited to relevant terms */ public function acf_query_terms($args, $field, $post_id) { - // This has been disabled as it blocks the normal select2 from working - // it requires deeper under the hood work. - // foreach ($this->props as $prop) { - // if ($field['name'] === $prop['acf']) { - // $args['search'] = $prop['search']; - // break; - // } - // } + // Find the root term to filter by + foreach ($this->props as $slug => $prop) { + if ($field['name'] === $prop['acf']) { + // Get the root term object + $term = get_term_by('slug', $slug, EMBL_Taxonomy::TAXONOMY_NAME); + if ( ! ($term instanceof WP_Term) || $term->slug !== $slug) { + break; + } + // Get the root term meta IDs + $meta = get_term_meta($term->term_id, EMBL_Taxonomy::META_IDS, true); + if ( ! is_array($meta) || ! count($meta)) { + break; + } + // Filter EMBL taxonomy terms where the root term is a parent + // Parent term IDs are stored in the serialized array meta value + // These args are for a `LIKE` keyword search on the serialized string + // Because IDs are 36 character UUIDs it should not return false positives + $args['meta_key'] = EMBL_Taxonomy::META_IDS; + $args['meta_value'] = $meta[0]; + $args['meta_compare'] = 'LIKE'; + break; + } + } return $args; }