$max - 1) { $s = mb_substr($s, 0, mb_strlen($s, 'UTF-8') - 1, 'UTF-8'); } return rtrim($s) . '…'; } /** * Plain, single-line text: no tags, no entities, no invalid UTF-8, at most $max * characters. * * @param mixed $v Raw value. * @param int $max Maximum length. * @return string */ function relaybell_catalog_text($v, $max) { if (!is_scalar($v)) { return ''; } $v = wp_check_invalid_utf8((string) $v, true); $v = html_entity_decode(wp_strip_all_tags($v), ENT_QUOTES, 'UTF-8'); $v = preg_replace('/[\s\x{00A0}]+/u', ' ', $v); return relaybell_catalog_cut(trim((string) $v), $max); } /** * A short plain-text summary of HTML content (shortcodes and blocks removed). * * @param string $html Content or excerpt. * @return string At most 300 characters. */ function relaybell_catalog_summary($html) { $html = strip_shortcodes((string) $html); if (function_exists('excerpt_remove_blocks')) { $html = excerpt_remove_blocks($html); } // Block and paragraph ends become spaces, so words don't run together. $html = preg_replace('/<\/(p|div|li|h[1-6])>|/i', ' ', $html); return relaybell_catalog_text($html, 300); } /** * An http(s) address, or '' when the value isn't one. * * @param mixed $u Raw address. * @return string */ function relaybell_catalog_url($u) { if (!is_string($u) || $u === '') { return ''; } $u = esc_url_raw($u, array('http', 'https')); return ($u !== '' && strlen($u) <= 2048) ? $u : ''; } /** * A price relaybell accepts (a number from 0 to under a trillion), or null. * * @param mixed $v Raw price. * @return float|null */ function relaybell_catalog_price($v) { if (!is_numeric($v)) { return null; } $v = (float) $v; return ($v >= 0 && $v < 1e12) ? round($v, 4) : null; } /* ------------------------------------------------------------ Taxonomies */ /** * The term a post is filed under first: the primary term set in Yoast SEO or Rank * Math, else the first one that isn't the default category ("Uncategorized"). * * @param int $post_id Post ID. * @param string $taxonomy Taxonomy. * @return WP_Term|null */ function relaybell_catalog_primary_term($post_id, $taxonomy) { $terms = get_the_terms($post_id, $taxonomy); if (!$terms || is_wp_error($terms)) { return null; } foreach (array('_yoast_wpseo_primary_' . $taxonomy, 'rank_math_primary_' . $taxonomy) as $meta) { $want = (int) get_post_meta($post_id, $meta, true); if ($want) { foreach ($terms as $term) { if ((int) $term->term_id === $want) { return $term; } } } } $default = (int) get_option($taxonomy === 'product_cat' ? 'default_product_cat' : 'default_category'); foreach ($terms as $term) { if ((int) $term->term_id !== $default) { return $term; } } return reset($terms); } /** * The category path of a post, e.g. "Coffee > Single origin". * * @param int $post_id Post ID. * @param string $taxonomy Hierarchical taxonomy (category, product_cat…). * @return string At most 300 characters; '' when it has none, or only the default * category. */ function relaybell_catalog_category_path($post_id, $taxonomy) { if ($taxonomy === '' || !taxonomy_exists($taxonomy)) { return ''; } $term = relaybell_catalog_primary_term($post_id, $taxonomy); // Filed only under the default category ("Uncategorized"): that says nothing about // what it is, and would lump unrelated items together in subscribers' interests. $default = ($taxonomy === 'category' || $taxonomy === 'product_cat') ? (int) get_option($taxonomy === 'product_cat' ? 'default_product_cat' : 'default_category') : 0; if (!$term || ($default && (int) $term->term_id === $default)) { return ''; } $names = array(); foreach (array_reverse(get_ancestors((int) $term->term_id, $taxonomy, 'taxonomy')) as $ancestor_id) { $ancestor = get_term($ancestor_id, $taxonomy); if ($ancestor instanceof WP_Term) { $names[] = $ancestor->name; } } $names[] = $term->name; $parts = array(); foreach ($names as $name) { // A ">" inside a name would split the path. $name = trim(str_replace('>', '-', relaybell_catalog_text($name, 100))); if ($name !== '') { $parts[] = $name; } } return relaybell_catalog_cut(implode(' > ', $parts), 300); } /** * Names of a post's terms in a taxonomy (tags), each at most 60 characters. * * @param int $post_id Post ID. * @param string $taxonomy Taxonomy. * @param int $max How many. * @return string[] */ function relaybell_catalog_term_names($post_id, $taxonomy, $max) { if ($taxonomy === '' || !taxonomy_exists($taxonomy)) { return array(); } $terms = get_the_terms($post_id, $taxonomy); if (!$terms || is_wp_error($terms)) { return array(); } $out = array(); foreach ($terms as $term) { $name = relaybell_catalog_text($term->name, 60); if ($name !== '' && !in_array($name, $out, true)) { $out[] = $name; } if (count($out) >= $max) { break; } } return $out; } /** * The category and tag taxonomies of a content post type: 'category' and * 'post_tag' for posts; for other types, their first public hierarchical and * non-hierarchical taxonomies. * * @param string $post_type Post type. * @return array{cat:string,tag:string} */ function relaybell_catalog_taxonomies($post_type) { if ($post_type === 'post') { return array('cat' => 'category', 'tag' => 'post_tag'); } $out = array('cat' => '', 'tag' => ''); foreach (get_object_taxonomies($post_type, 'objects') as $name => $tax) { if (empty($tax->public) || $name === 'post_format') { continue; } $slot = !empty($tax->hierarchical) ? 'cat' : 'tag'; if ($out[$slot] === '') { $out[$slot] = $name; } } return $out; } /** * A product's brand, from a brand taxonomy when the store has one (WooCommerce * Brands, Perfect Brands, YITH Brands, BeRocket Brands). * * @param int $product_id Product ID. * @return string At most 120 characters. */ function relaybell_catalog_brand($product_id) { /** * Filter the taxonomies a product's brand is read from (the first one with a * term wins). * * @param string[] $taxonomies Taxonomy names. */ $taxonomies = apply_filters('relaybell_catalog_brand_taxonomies', array('product_brand', 'pwb-brand', 'yith_product_brand', 'berocket_brand')); foreach ((array) $taxonomies as $taxonomy) { if (!is_string($taxonomy) || !taxonomy_exists($taxonomy)) { continue; } $term = relaybell_catalog_primary_term($product_id, $taxonomy); if ($term) { return relaybell_catalog_text($term->name, 120); } } return ''; } /* ----------------------------------------------------------- Catalog items */ /** * Whether a WooCommerce product may be in the catalog: published, not * password-protected and not hidden from the shop. (Variations are part of their * product.) * * @param WC_Product $product Product. * @return bool */ function relaybell_catalog_product_is_public($product) { if (!($product instanceof WC_Product) || $product->is_type('variation') || $product->get_status('edit') !== 'publish') { return false; } $password = method_exists($product, 'get_post_password') ? (string) $product->get_post_password('edit') : (string) get_post_field('post_password', $product->get_id()); return $password === '' && $product->get_catalog_visibility('edit') !== 'hidden'; } /** * Regular and sale price of a product as shoppers see it. A variable product is * listed with its cheapest option (that option's regular and sale price). * * @param WC_Product $product Product. * @return array{0:float|null,1:float|null} Price, and the sale price when lower. */ function relaybell_catalog_product_prices($product) { $active = null; $regular = null; if ($product->is_type('variable') && method_exists($product, 'get_variation_prices')) { $prices = $product->get_variation_prices(false); if (!empty($prices['price']) && is_array($prices['price'])) { asort($prices['price'], SORT_NUMERIC); $vid = key($prices['price']); $active = $prices['price'][$vid]; $regular = isset($prices['regular_price'][$vid]) ? $prices['regular_price'][$vid] : $active; } } elseif (!$product->is_type('grouped')) { $active = $product->get_price(); $regular = $product->get_regular_price(); } $active = relaybell_catalog_price($active); $regular = relaybell_catalog_price($regular); if ($regular === null || $regular <= 0) { return array($active, null); } return array($regular, ($active !== null && $active < $regular) ? $active : null); } /** * The store currency, when it is a three-letter code. * * @return string */ function relaybell_catalog_currency() { $c = function_exists('get_woocommerce_currency') ? strtoupper((string) get_woocommerce_currency()) : ''; return preg_match('/^[A-Z]{3}$/', $c) ? $c : ''; } /** * The catalog item for a WooCommerce product. * * @param WC_Product $product Product. * @return array|null Null when it isn't public (or a filter left it out). */ function relaybell_catalog_product_item($product) { if (!relaybell_catalog_product_is_public($product)) { return null; } $id = (int) $product->get_id(); $item = array( 'id' => (string) $id, 'type' => 'product', 'title' => relaybell_catalog_text($product->get_name('edit'), 300), 'url' => relaybell_catalog_url(get_permalink($id)), ); $image_id = (int) $product->get_image_id('edit'); if ($image_id) { $item['image'] = relaybell_catalog_url((string) wp_get_attachment_image_url($image_id, 'woocommerce_single')); } list($price, $sale) = relaybell_catalog_product_prices($product); if ($price !== null) { $item['price'] = $price; if ($sale !== null) { $item['salePrice'] = $sale; } $currency = relaybell_catalog_currency(); if ($currency !== '') { $item['currency'] = $currency; } } $item['category'] = relaybell_catalog_category_path($id, 'product_cat'); $item['brand'] = relaybell_catalog_brand($id); $item['tags'] = relaybell_catalog_term_names($id, 'product_tag', 20); $summary = (string) $product->get_short_description('edit'); $item['summary'] = relaybell_catalog_summary($summary !== '' ? $summary : (string) $product->get_description('edit')); $stock = (string) $product->get_stock_status('edit'); $map = array('instock' => 'in_stock', 'outofstock' => 'out_of_stock', 'onbackorder' => 'preorder'); $item['availability'] = isset($map[$stock]) ? $map[$stock] : 'in_stock'; $created = $product->get_date_created('edit'); if ($created) { $item['publishedAt'] = gmdate('Y-m-d\TH:i:s\Z', $created->getTimestamp()); } return relaybell_catalog_finish_item($item, $product); } /** * The catalog item for a post (or another synced content type). * * @param WP_Post $post Post. * @return array|null Null when it isn't public (or a filter left it out). */ function relaybell_catalog_post_item($post) { if (!($post instanceof WP_Post) || $post->post_status !== 'publish' || (string) $post->post_password !== '' || !in_array($post->post_type, relaybell_catalog_post_types(), true)) { return null; } /** * Filter the catalog type of a post: 'article' (default), 'course', 'listing', * 'product' or 'other'. * * @param string $type Item type. * @param WP_Post $post The post. */ $type = apply_filters('relaybell_catalog_item_type', 'article', $post); if (!in_array($type, array('article', 'course', 'listing', 'product', 'other'), true)) { $type = 'article'; } $tax = relaybell_catalog_taxonomies($post->post_type); $item = array( 'id' => (string) $post->ID, 'type' => $type, 'title' => relaybell_catalog_text(get_the_title($post), 300), 'url' => relaybell_catalog_url(get_permalink($post)), 'category' => relaybell_catalog_category_path($post->ID, $tax['cat']), 'tags' => relaybell_catalog_term_names($post->ID, $tax['tag'], 20), 'summary' => relaybell_catalog_summary(has_excerpt($post) ? $post->post_excerpt : $post->post_content), 'availability' => 'in_stock', ); $image = get_the_post_thumbnail_url($post, 'large'); if ($image) { $item['image'] = relaybell_catalog_url($image); } $published = get_post_time('U', true, $post); if ($published) { $item['publishedAt'] = gmdate('Y-m-d\TH:i:s\Z', (int) $published); } return relaybell_catalog_finish_item($item, $post); } /** * Pass an item through the relaybell_catalog_item filter, then make sure it still * fits what relaybell accepts (empty fields are left out). * * @param array $item Catalog item. * @param WC_Product|WP_Post $source The product or post. * @return array|null */ function relaybell_catalog_finish_item($item, $source) { /** * Filter a catalog item before it is sent to relaybell. Return null to leave * the product or post out of relaybell's catalog. * * @param array $item { id, type, title, url, image, price, salePrice, * currency, category, brand, tags, summary, * availability, publishedAt }. * @param WC_Product|WP_Post $source The product or post. */ $item = apply_filters('relaybell_catalog_item', $item, $source); if (!is_array($item)) { return null; } $id = isset($item['id']) && is_scalar($item['id']) ? trim((string) $item['id']) : ''; $title = isset($item['title']) ? relaybell_catalog_text($item['title'], 300) : ''; if ($id === '' || strlen($id) > 128 || $title === '') { return null; } $types = array('product', 'article', 'course', 'listing', 'other'); $out = array( 'id' => $id, 'type' => (isset($item['type']) && in_array($item['type'], $types, true)) ? $item['type'] : 'product', 'title' => $title, ); foreach (array('url', 'image') as $k) { $v = isset($item[$k]) ? relaybell_catalog_url($item[$k]) : ''; if ($v !== '') { $out[$k] = $v; } } foreach (array('price', 'salePrice') as $k) { $v = isset($item[$k]) ? relaybell_catalog_price($item[$k]) : null; if ($v !== null) { $out[$k] = $v; } } if (isset($item['currency']) && is_string($item['currency']) && preg_match('/^[A-Za-z]{3}$/', $item['currency'])) { $out['currency'] = strtoupper($item['currency']); } foreach (array('category' => 300, 'brand' => 120, 'summary' => 300) as $k => $max) { $v = isset($item[$k]) ? relaybell_catalog_text($item[$k], $max) : ''; if ($v !== '') { $out[$k] = $v; } } if (!empty($item['tags']) && is_array($item['tags'])) { $tags = array(); foreach ($item['tags'] as $tag) { $tag = relaybell_catalog_text($tag, 60); if ($tag !== '' && !in_array($tag, $tags, true)) { $tags[] = $tag; } } if ($tags) { $out['tags'] = array_slice($tags, 0, 20); } } $availability = array('in_stock', 'out_of_stock', 'preorder', 'unavailable'); $out['availability'] = (isset($item['availability']) && in_array($item['availability'], $availability, true)) ? $item['availability'] : 'in_stock'; if (isset($item['publishedAt']) && is_string($item['publishedAt']) && strtotime($item['publishedAt'])) { $out['publishedAt'] = gmdate('Y-m-d\TH:i:s\Z', (int) strtotime($item['publishedAt'])); } return $out; } /** * What to send for one product or post id now: its catalog item, 'delete' (it is * gone, unpublished, hidden or protected), or 'skip' (its kind isn't synced). * * @param int $post_id Post ID. * @return array|string */ function relaybell_catalog_item_for($post_id) { $post = get_post($post_id); if (!($post instanceof WP_Post)) { return 'delete'; } if ($post->post_type === 'product') { if (!relaybell_catalog_products_on()) { return 'skip'; } $product = wc_get_product($post); $item = $product ? relaybell_catalog_product_item($product) : null; return $item ? $item : 'delete'; } if (in_array($post->post_type, relaybell_catalog_post_types(), true)) { $item = relaybell_catalog_post_item($post); return $item ? $item : 'delete'; } return 'skip'; } /* -------------------------------------------------------- Talking to relaybell */ /** * POST JSON to relaybell with the site's API key (background runs only). * * @param string $path API path, e.g. /api/v1/catalog/items. * @param array $body Request body. * @return array{ok:bool,code:int,body?:array,text?:string} */ function relaybell_sync_post($path, $body) { $json = wp_json_encode($body); if ($json === false) { return array('ok' => false, 'code' => 0, 'text' => __('Some of the data couldn’t be prepared for sending.', 'relaybell')); } $res = wp_remote_post(relaybell_base() . $path, array( 'headers' => array( 'Authorization' => 'Bearer ' . trim((string) get_option('relaybell_api_key')), 'Content-Type' => 'application/json', ), 'body' => $json, 'timeout' => 15, )); if (is_wp_error($res)) { /* translators: %s: network error message */ return array('ok' => false, 'code' => 0, 'text' => sprintf(__('This server couldn’t reach relaybell: %s', 'relaybell'), $res->get_error_message())); } $code = (int) wp_remote_retrieve_response_code($res); $b = json_decode(wp_remote_retrieve_body($res), true); if ($code >= 200 && $code < 300 && is_array($b)) { return array('ok' => true, 'code' => $code, 'body' => $b); } if ($code === 401 || $code === 403) { $text = __('relaybell rejected the API key. Disconnect, then connect again.', 'relaybell'); } elseif ($code === 404) { $text = __('relaybell doesn’t accept this yet (HTTP 404).', 'relaybell'); } elseif ($code === 429) { $text = __('relaybell asked to slow down for a moment.', 'relaybell'); } else { /* translators: %d: HTTP status code */ $text = sprintf(__('relaybell returned HTTP %d.', 'relaybell'), $code); } return array('ok' => false, 'code' => $code, 'text' => $text); } /** * Seconds to wait before trying again after $errors failures in a row (1 minute, * doubling, at most an hour). * * @param int $errors Failures in a row. * @return int */ function relaybell_sync_backoff($errors) { return (int) min(HOUR_IN_SECONDS, MINUTE_IN_SECONDS * pow(2, max(0, min(10, (int) $errors - 1)))); } /* --------------------------------------------------------------- State */ /** * The catalog sync's state (option relaybell_catalog_state). * * @return array */ function relaybell_catalog_state() { $st = get_option('relaybell_catalog_state', array()); return is_array($st) ? $st : array(); } /** * @param array $st State to save. * @return void */ function relaybell_catalog_save_state($st) { update_option('relaybell_catalog_state', $st, false); } /** * The past-orders import's state (option relaybell_import_state). * * @return array */ function relaybell_import_state() { $st = get_option('relaybell_import_state', array()); return is_array($st) ? $st : array(); } /** * Whether a full sync or an import is under way (the settings page then refreshes * their status). * * @return bool */ function relaybell_sync_running() { $catalog = relaybell_catalog_state(); $import = relaybell_import_state(); return !empty($catalog['running']) || !empty($import['running']); } /** * Start a full sync of the catalog: the next background runs send every product * and post again, under a new fullSync id, and relaybell then drops the items it * didn't get (deleted or hidden since). A sync already running starts over. * Nothing is sent here. * * With nothing to sync (both boxes off), it runs only once after items were sent, * to remove them from relaybell. * * @param string $why What asked for it (kept in the state, for support). * @return bool Whether a sync was started. */ function relaybell_catalog_request_full($why = '') { if (!relaybell_is_connected()) { return false; } $st = relaybell_catalog_state(); $phases = relaybell_catalog_phases(); if (!$phases && (empty($st['last_full']) || !empty($st['last_full']['empty']))) { // Nothing to send, and nothing sent before that relaybell would still have. if (!empty($st['running'])) { $st['running'] = false; relaybell_catalog_save_state($st); } return false; } $st['id'] = 'wp-' . gmdate('YmdHis') . '-' . strtolower(wp_generate_password(6, false, false)); $st['running'] = true; $st['phases'] = $phases; $st['phase'] = 0; $st['cursor'] = 0; $st['sent'] = 0; $st['skipped'] = 0; $st['full'] = 0; $st['started'] = time(); $st['why'] = sanitize_key((string) $why); $st['errors'] = 0; $st['retry_at'] = 0; unset($st['error'], $st['attempted']); relaybell_catalog_save_state($st); relaybell_sync_soon(0); return true; } /** * Forget everything about catalog sync and the import (disconnect, or another * relaybell project): stop the background runs. * * @return void */ function relaybell_catalog_forget() { delete_option('relaybell_catalog_state'); delete_option('relaybell_catalog_queue'); delete_option('relaybell_import_state'); delete_option('relaybell_sync_lock'); wp_clear_scheduled_hook('relaybell_sync_run'); } /** * Another relaybell project, key or host (Connect, or the Advanced form): that * project gets the whole catalog, so start over with a full sync. * * @return void */ function relaybell_catalog_on_connection_change() { delete_option('relaybell_catalog_state'); delete_option('relaybell_catalog_queue'); delete_option('relaybell_import_state'); relaybell_catalog_request_full('connect'); } /* ------------------------------------------------------- Background runs */ /** * Make sure a background run happens within $delay seconds (moving a later one * forward). * * @param int $delay Seconds from now. * @return void */ function relaybell_sync_soon($delay) { $at = time() + max(0, (int) $delay); $next = wp_next_scheduled('relaybell_sync_run'); if ($next && $next <= $at) { return; } if ($next) { wp_unschedule_event($next, 'relaybell_sync_run'); } wp_schedule_single_event($at, 'relaybell_sync_run'); } /** * One background run at a time. A run that died (a timeout, a fatal error) leaves * its lock behind; it is taken over after 90 seconds. (Best effort, like WP-Cron's * own lock: two runs at once would only send the same batch twice, which relaybell * handles.) * * @return bool Whether this run may go ahead. */ function relaybell_sync_lock() { if (add_option('relaybell_sync_lock', (string) time(), '', 'no')) { return true; } $since = (int) get_option('relaybell_sync_lock'); if ($since && time() - $since < 90) { return false; } update_option('relaybell_sync_lock', (string) time(), false); return true; } /** * The relaybell_sync_run event: one unit of each kind of pending work — the queued * product/post changes, the next batch of a full sync, the next batch of the * import — each skipped while it waits after an error. Runs again right away * while work is left (on the next WP-Cron run), later after an error, and not at * all when everything is sent. * * @return void */ function relaybell_sync_run() { if (!relaybell_is_connected()) { return; } if (!relaybell_sync_lock()) { // Another run is busy; it schedules what's next. This one checks back in case it died. if (!wp_next_scheduled('relaybell_sync_run')) { wp_schedule_single_event(time() + 100, 'relaybell_sync_run'); } return; } // A safety net if this run is cut short: the next one picks up where it stopped. $watchdog = 0; if (!wp_next_scheduled('relaybell_sync_run')) { $watchdog = time() + 3 * MINUTE_IN_SECONDS; wp_schedule_single_event($watchdog, 'relaybell_sync_run'); } $start = time(); $next = array(); try { foreach (array('relaybell_catalog_live_unit', 'relaybell_catalog_full_unit', 'relaybell_import_unit') as $unit) { // Each request may take up to 15 seconds: stay well inside PHP's time limit. $next[] = (time() - $start < 12) ? call_user_func($unit) : 0; } } finally { delete_option('relaybell_sync_lock'); } $next = array_filter($next, 'is_int'); if ($next) { relaybell_sync_soon(min($next)); } elseif ($watchdog) { wp_unschedule_event($watchdog, 'relaybell_sync_run'); } } add_action('relaybell_sync_run', 'relaybell_sync_run'); /** * A unit that waits after an error: seconds left, or 0 when it may go. * * @param array $st State. * @param string $key Key holding the retry time. * @return int */ function relaybell_sync_wait_left($st, $key) { $at = isset($st[$key]) ? (int) $st[$key] : 0; return $at > time() ? $at - time() : 0; } /** * Note a failed request in a state array (shown on the settings page). * * @param array $st State. * @param array $res From relaybell_sync_post(). * @param string $errors Key counting failures in a row. * @param string $retry Key holding the retry time. * @return int Seconds until the next try. */ function relaybell_sync_failed(&$st, $res, $errors, $retry) { $st[$errors] = (isset($st[$errors]) ? (int) $st[$errors] : 0) + 1; $wait = relaybell_sync_backoff($st[$errors]); $st[$retry] = time() + $wait; $st['error'] = array('at' => time(), 'text' => isset($res['text']) ? (string) $res['text'] : ''); return $wait; } /* -------------------------------------------- Live updates (edits, stock…) */ /** * Ids changed in this request (per site, on multisite), saved into the queue * option once, at the end of the request. * * @param string $op 'add' or 'take'. * @param int $id Post ID for 'add' (on the current site). * @return array For 'take': site id => array( post id => true ). */ function relaybell_catalog_pending($op, $id = 0) { static $ids = array(); static $hooked = false; if ($op === 'take') { $out = $ids; $ids = array(); return $out; } $ids[get_current_blog_id()][(int) $id] = true; if (!$hooked) { $hooked = true; add_action('shutdown', 'relaybell_catalog_save_pending', 1); } return array(); } /** * Shutdown: add this request's changed ids to the queue and make sure a background * run sends them shortly. Only an option write — saving never waits for relaybell. * * @return void */ function relaybell_catalog_save_pending() { foreach (relaybell_catalog_pending('take') as $blog => $ids) { // Each change goes to the queue of the site it was made on. $switched = is_multisite() && (int) $blog !== get_current_blog_id(); if ($switched) { switch_to_blog((int) $blog); } relaybell_catalog_queue_ids(array_keys($ids)); if ($switched) { restore_current_blog(); } } } /** * Add ids to the current site's queue and make sure a background run sends them * shortly. * * @param int[] $ids Post IDs. * @return void */ function relaybell_catalog_queue_ids($ids) { if (!$ids || !relaybell_is_connected()) { return; } $queue = get_option('relaybell_catalog_queue', array()); $queue = is_array($queue) ? $queue : array(); $now = time(); foreach ($ids as $id) { $queue[$id] = $now; } if (count($queue) > 5000) { // A bulk change of thousands of items: a full sync sends them all anyway. delete_option('relaybell_catalog_queue'); relaybell_catalog_request_full('bulk'); return; } update_option('relaybell_catalog_queue', $queue, false); relaybell_sync_soon(10); } /** * Note a product or post change, when that kind is synced. * * @param WP_Post|null $post The post. * @return void */ function relaybell_catalog_watch($post) { if (!($post instanceof WP_Post) || !relaybell_is_connected()) { return; } if ($post->post_type === 'product' ? relaybell_catalog_products_on() : in_array($post->post_type, relaybell_catalog_post_types(), true)) { relaybell_catalog_pending('add', (int) $post->ID); } } // Published, unpublished, trashed, scheduled post going live… add_action('transition_post_status', function ($new_status, $old_status, $post) { if ($new_status === 'publish' || $old_status === 'publish') { relaybell_catalog_watch($post); } }, 10, 3); // Edited while published. add_action('wp_after_insert_post', function ($post_id, $post) { if ($post instanceof WP_Post && $post->post_status === 'publish') { relaybell_catalog_watch($post); } }, 10, 2); // Deleted for good while published (trashed posts left the catalog already). add_action('before_delete_post', function ($post_id, $post = null) { $post = ($post instanceof WP_Post) ? $post : get_post($post_id); if ($post instanceof WP_Post && $post->post_status === 'publish') { relaybell_catalog_watch($post); } }, 10, 2); /** * WooCommerce saves, stock and price changes (a variation's change is its * product's): note the product when it is published. * * @param mixed $arg Product id or WC_Product. * @return void */ function relaybell_catalog_watch_product($arg) { if (!function_exists('wc_get_product') || !relaybell_is_connected() || !relaybell_catalog_products_on()) { return; } $product = ($arg instanceof WC_Product) ? $arg : wc_get_product(absint($arg)); if (!($product instanceof WC_Product)) { return; } $id = $product->is_type('variation') ? (int) $product->get_parent_id() : (int) $product->get_id(); if ($id && get_post_status($id) === 'publish') { relaybell_catalog_pending('add', $id); } } foreach (array( 'woocommerce_new_product', 'woocommerce_update_product', 'woocommerce_product_set_stock_status', 'woocommerce_variation_set_stock_status', 'woocommerce_product_set_stock', 'woocommerce_variation_set_stock', 'woocommerce_new_product_variation', 'woocommerce_update_product_variation', ) as $relaybell_hook) { add_action($relaybell_hook, 'relaybell_catalog_watch_product', 20, 1); } unset($relaybell_hook); /** * Background unit: send up to 100 queued changes (items that are still public are * sent again, the others are removed from relaybell). * * @return int|null Seconds until it needs to run again; null when idle. */ function relaybell_catalog_live_unit() { $queue = get_option('relaybell_catalog_queue', array()); if (!is_array($queue) || !$queue) { return null; } $st = relaybell_catalog_state(); $wait = relaybell_sync_wait_left($st, 'live_retry_at'); if ($wait) { return $wait; } asort($queue, SORT_NUMERIC); $take = array_slice($queue, 0, RELAYBELL_CATALOG_BATCH, true); $upserts = array(); $deletes = array(); foreach ($take as $id => $ts) { try { $what = relaybell_catalog_item_for((int) $id); } catch (\Throwable $e) { $what = 'skip'; // Another plugin's filter failed on it: leave it for the next full sync. } if (is_array($what)) { $upserts[] = $what; } elseif ($what === 'delete') { $deletes[] = (string) (int) $id; } } if ($upserts) { $body = array('items' => $upserts); if (!empty($st['running']) && !empty($st['id'])) { // Counted as seen by the full sync running now, so its end doesn't drop them. $body['fullSync'] = array('id' => (string) $st['id'], 'done' => false); } $res = relaybell_sync_post('/api/v1/catalog/items', $body); if (!$res['ok']) { $wait = relaybell_sync_failed($st, $res, 'live_errors', 'live_retry_at'); relaybell_catalog_save_state($st); return $wait; } } if ($deletes) { $res = relaybell_sync_post('/api/v1/catalog/items/delete', array('ids' => $deletes)); if (!$res['ok']) { $wait = relaybell_sync_failed($st, $res, 'live_errors', 'live_retry_at'); relaybell_catalog_save_state($st); return $wait; } } // Sent: take them off the queue — unless they changed again meanwhile (another // request may have written the queue since it was read). wp_cache_delete('relaybell_catalog_queue', 'options'); $queue = get_option('relaybell_catalog_queue', array()); $queue = is_array($queue) ? $queue : array(); foreach ($take as $id => $ts) { if (isset($queue[$id]) && (int) $queue[$id] <= (int) $ts) { unset($queue[$id]); } } update_option('relaybell_catalog_queue', $queue, false); $st = relaybell_catalog_state(); $st['live_at'] = time(); $st['live_sent'] = count($upserts) + count($deletes); $st['live_errors'] = 0; $st['live_retry_at'] = 0; if (empty($st['errors'])) { unset($st['error']); } relaybell_catalog_save_state($st); return $queue ? 0 : null; } /* ------------------------------------------------------------- Full sync */ /** * The next published ids of one kind below $before (0: from the newest), newest first: * past relaybell's item limit for one website the newest posts must already be there. * * @param string $post_type Post type. * @param int $before Last id already sent (0 for none yet). * @param int $limit How many. * @return int[] */ function relaybell_catalog_next_ids($post_type, $before, $limit) { global $wpdb; // WP_Query can't page by "id less than", and page numbers would skip items when // others are deleted during the sync (relaybell would then drop them at the end). if ((int) $before > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A read-only, indexed, LIMITed id scan run by the background sync; caching would serve stale ids. $ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' AND ID < %d ORDER BY ID DESC LIMIT %d", $post_type, (int) $before, (int) $limit )); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A read-only, indexed, LIMITed id scan run by the background sync; caching would serve stale ids. $ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' ORDER BY ID DESC LIMIT %d", $post_type, (int) $limit )); } return array_map('intval', (array) $ids); } /** * Background unit: the next batch of a full sync (100 items, with fullSync * { id, done }). The position moves on only once relaybell has the batch, so an * interrupted or failed run sends the same batch again. * * @return int|null Seconds until it needs to run again; null when idle. */ function relaybell_catalog_full_unit() { $st = relaybell_catalog_state(); if (empty($st['running']) || empty($st['id'])) { return null; } $wait = relaybell_sync_wait_left($st, 'retry_at'); if ($wait) { return $wait; } $phases = isset($st['phases']) && is_array($st['phases']) ? array_values($st['phases']) : array(); $count = count($phases); $phase = isset($st['phase']) ? (int) $st['phase'] : 0; $cursor = isset($st['cursor']) ? (int) $st['cursor'] : 0; $groups = array(); $taken = 0; while ($taken < RELAYBELL_CATALOG_BATCH && $phase < $count) { $want = RELAYBELL_CATALOG_BATCH - $taken; $ids = relaybell_catalog_next_ids($phases[$phase], $cursor, $want); if ($ids) { $groups[] = array($phases[$phase], $ids); $taken += count($ids); $cursor = (int) end($ids); } if (count($ids) < $want) { ++$phase; $cursor = 0; } } // Done when nothing is left after this batch (so the last batch carries done=true). $done = true; for ($p = $phase; $p < $count; $p++) { if (relaybell_catalog_next_ids($phases[$p], $p === $phase ? $cursor : 0, 1)) { $done = false; break; } } $items = array(); foreach ($groups as $group) { list($type, $ids) = $group; // One query for the posts, their meta and terms. new WP_Query(array( 'post__in' => $ids, 'post_type' => $type, 'post_status' => 'publish', 'posts_per_page' => count($ids), 'orderby' => 'post__in', 'no_found_rows' => true, 'ignore_sticky_posts' => true, )); foreach ($ids as $id) { try { if ($type === 'product') { $product = wc_get_product($id); $item = $product ? relaybell_catalog_product_item($product) : null; } else { $item = relaybell_catalog_post_item(get_post($id)); } } catch (\Throwable $e) { $item = null; // Another plugin's filter failed on it: left out. } if ($item) { $items[] = $item; } } } $res = array('ok' => true, 'body' => array()); if ($items || $done) { $res = relaybell_sync_post('/api/v1/catalog/items', array( 'items' => $items, 'fullSync' => array('id' => (string) $st['id'], 'done' => $done), )); } $id = (string) $st['id']; $st = relaybell_catalog_state(); if (empty($st['id']) || (string) $st['id'] !== $id) { return 0; // A new sync was asked for meanwhile: it starts on the next run. } if (!$res['ok']) { $wait = relaybell_sync_failed($st, $res, 'errors', 'retry_at'); relaybell_catalog_save_state($st); return $wait; } $b = $res['body']; $st['phase'] = $phase; $st['cursor'] = $cursor; $st['sent'] = (isset($st['sent']) ? (int) $st['sent'] : 0) + count($items); $skips = (isset($b['skipped']) && is_array($b['skipped'])) ? $b['skipped'] : array(); $st['skipped'] = (isset($st['skipped']) ? (int) $st['skipped'] : 0) + count($skips); // Items past relaybell's limit for one website come back as 'catalog_full'. $full = 0; foreach ($skips as $skip) { $full += (is_array($skip) && isset($skip['reason']) && $skip['reason'] === 'catalog_full') ? 1 : 0; } $st['full'] = (isset($st['full']) ? (int) $st['full'] : 0) + $full; $st['errors'] = 0; $st['retry_at'] = 0; $st['attempted'] = time(); if (empty($st['live_errors'])) { unset($st['error']); } if ($done) { $st['running'] = false; $st['last_full'] = array( 'at' => time(), 'sent' => (int) $st['sent'], 'skipped' => (int) $st['skipped'], 'full' => isset($st['full']) ? (int) $st['full'] : 0, 'removed' => isset($b['removed']) ? (int) $b['removed'] : 0, 'empty' => $count === 0, ); } relaybell_catalog_save_state($st); return $done ? null : 0; } /* ---------------------------------------------------------- Past orders */ /** * Whether past orders can be imported: WooCommerce is active, and customers are * linked by email (that's how relaybell finds their subscription). * * @return bool */ function relaybell_import_available() { return class_exists('WooCommerce') && function_exists('wc_get_orders') && function_exists('relaybell_woo_order_external_id'); } /** * The order statuses imported — the same ones that send a live purchase event. * * @return string[] */ function relaybell_import_statuses() { $paid = function_exists('wc_get_is_paid_statuses') ? (array) wc_get_is_paid_statuses() : array('processing', 'completed'); return array_values(array_unique(array_merge($paid, array('on-hold')))); } /** * Start importing the last 90 days of orders in the background (or start over). * Nothing is sent here. * * @return bool */ function relaybell_import_start() { if (!relaybell_is_connected() || !relaybell_import_available()) { return false; } $now = time(); update_option('relaybell_import_state', array( 'running' => true, // An hour's margin, so the oldest orders are still inside relaybell's 90 days // when they arrive. 'from' => $now - RELAYBELL_IMPORT_DAYS * DAY_IN_SECONDS + HOUR_IN_SECONDS, 'until' => $now, 'page' => 0, 'checked' => 0, 'sent' => 0, 'imported' => 0, 'already' => 0, 'old' => 0, 'skipped' => 0, 'started' => $now, 'errors' => 0, 'retry_at' => 0, ), false); relaybell_sync_soon(0); return true; } /** * Background unit: the next 50 orders of the import, sent as historic purchase * events (the same customer id and details as a live purchase event). * * @return int|null Seconds until it needs to run again; null when idle. */ function relaybell_import_unit() { $st = relaybell_import_state(); if (empty($st['running'])) { return null; } if (!relaybell_import_available()) { $st['running'] = false; update_option('relaybell_import_state', $st, false); return null; } $wait = relaybell_sync_wait_left($st, 'retry_at'); if ($wait) { return $wait; } $orders = wc_get_orders(array( 'type' => 'shop_order', 'status' => relaybell_import_statuses(), 'date_created' => (int) $st['from'] . '...' . (int) $st['until'], 'orderby' => 'ID', 'order' => 'ASC', 'limit' => RELAYBELL_IMPORT_BATCH, 'paged' => (int) $st['page'] + 1, )); $orders = is_array($orders) ? $orders : array(); $events = array(); $sent = array(); foreach ($orders as $order) { if (!($order instanceof WC_Order)) { continue; } $external_id = relaybell_woo_order_external_id($order); $when = $order->get_date_paid() ? $order->get_date_paid() : $order->get_date_created(); if ($external_id === '' || !$when) { continue; // Nobody to match it to. } $sent[] = $order; $events[] = array( 'event' => 'purchase', 'externalId' => $external_id, 'properties' => relaybell_woo_order_properties($order), 'timestamp' => gmdate('Y-m-d\TH:i:s\Z', $when->getTimestamp()), ); } $b = array(); if ($events) { $res = relaybell_sync_post('/api/v1/events/import', array('events' => $events)); if (!$res['ok']) { $wait = relaybell_sync_failed($st, $res, 'errors', 'retry_at'); update_option('relaybell_import_state', $st, false); return $wait; } $b = $res['body']; // relaybell has these orders now: a later status change (on-hold → processing, // processing → completed) must not send their purchase again, as for orders // sent live (relaybell_woo_on_purchase()). foreach ($sent as $order) { if ($order->get_meta('_relaybell_purchase_sent') !== 'yes') { $order->update_meta_data('_relaybell_purchase_sent', 'yes'); $order->save_meta_data(); } } } $already = 0; $old = 0; $skipped = 0; if (isset($b['skipped']) && is_array($b['skipped'])) { foreach ($b['skipped'] as $why => $n) { $n = is_numeric($n) ? (int) $n : 0; if ($why === 'duplicate') { $already += $n; } elseif ($why === 'tooOld') { $old += $n; // Older than the activity relaybell keeps for this website. } else { $skipped += $n; } } } $st['page'] = (int) $st['page'] + 1; $st['checked'] = (int) $st['checked'] + count($orders); $st['sent'] = (int) $st['sent'] + count($events); $st['imported'] = (int) $st['imported'] + (isset($b['imported']) ? (int) $b['imported'] : 0); $st['already'] = (isset($st['already']) ? (int) $st['already'] : 0) + $already; $st['old'] = (isset($st['old']) ? (int) $st['old'] : 0) + $old; $st['skipped'] = (int) $st['skipped'] + $skipped + (count($orders) - count($events)); $st['errors'] = 0; $st['retry_at'] = 0; unset($st['error']); $done = count($orders) < RELAYBELL_IMPORT_BATCH; if ($done) { $st['running'] = false; $st['finished'] = time(); } update_option('relaybell_import_state', $st, false); return $done ? null : 0; } /* ------------------------------------------------- Scheduling & safety nets */ /** * Once a day (relaybell_identity_daily): a full sync each week, so category and * other changes WordPress doesn't report reach relaybell; and a background run * when work is waiting but none is scheduled (e.g. after the plugin was switched * off and on). * * @return void */ function relaybell_catalog_daily() { if (!relaybell_is_connected()) { return; } $st = relaybell_catalog_state(); $last = isset($st['last_full']['at']) ? (int) $st['last_full']['at'] : 0; if (empty($st['running']) && time() - $last > WEEK_IN_SECONDS) { relaybell_catalog_request_full($last ? 'weekly' : 'first'); return; } $queue = get_option('relaybell_catalog_queue', array()); $import = relaybell_import_state(); if ((!empty($st['running']) || !empty($queue) || !empty($import['running'])) && !wp_next_scheduled('relaybell_sync_run')) { relaybell_sync_soon(0); } } add_action('relaybell_identity_daily', 'relaybell_catalog_daily', 20); // "Send my products / posts" changed: a full sync sends what is now included, and // relaybell drops what no longer is. foreach (array('relaybell_catalog_products', 'relaybell_catalog_posts') as $relaybell_opt) { add_action('update_option_' . $relaybell_opt, function ($old, $new) { if ((string) $old !== (string) $new) { relaybell_catalog_request_full('settings'); } }, 10, 2); add_action('add_option_' . $relaybell_opt, function ($option, $value) { if ((string) $value === '0') { relaybell_catalog_request_full('settings'); } }, 10, 2); } unset($relaybell_opt); /* ------------------------------------------------------------ Admin actions */ /** * "Sync now": start a full sync in the background, then go back to the settings page. */ add_action('admin_post_relaybell_catalog_sync', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('Sorry, you are not allowed to do that.', 'relaybell')); } check_admin_referer('relaybell_catalog_sync'); $msg = relaybell_catalog_request_full('manual') ? 'sync' : 'sync_off'; wp_safe_redirect(add_query_arg('rb_msg', $msg, admin_url('options-general.php?page=relaybell')) . '#relaybell-p13n'); exit; }); /** * "Import the last 90 days of orders": start the import in the background. */ add_action('admin_post_relaybell_import_orders', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('Sorry, you are not allowed to do that.', 'relaybell')); } check_admin_referer('relaybell_import_orders'); $msg = (get_option('relaybell_identify', '1') === '1' && relaybell_import_start()) ? 'import' : 'import_off'; wp_safe_redirect(add_query_arg('rb_msg', $msg, admin_url('options-general.php?page=relaybell')) . '#relaybell-p13n'); exit; }); /** * The settings page refreshes the catalog and import status while they run. */ add_action('wp_ajax_relaybell_sync_status', function () { check_ajax_referer('relaybell_ajax'); if (!current_user_can('manage_options')) { wp_send_json_error(); } $catalog = relaybell_catalog_status(); $import = relaybell_import_status(); wp_send_json_success(array( 'catalog' => $catalog, 'import' => $import, 'running' => !empty($catalog['running']) || !empty($import['running']), )); }); /* ------------------------------------------------------------- Status text */ /** * The catalog sync's status in plain words. * * @return array{state:string,text:string,running:bool} */ function relaybell_catalog_status() { $st = relaybell_catalog_state(); if (!relaybell_catalog_phases()) { $text = class_exists('WooCommerce') ? __('Off: no products or posts are sent to relaybell. Tick “Send my products” or “Send my posts” below to recommend them in your notifications.', 'relaybell') : __('Off: no posts are sent to relaybell. Tick “Send my posts” below to recommend them in your notifications.', 'relaybell'); return array('state' => 'info', 'text' => $text, 'running' => !empty($st['running'])); } $parts = array(); $state = 'ok'; if (!empty($st['running'])) { $state = 'info'; if (empty($st['sent']) && empty($st['attempted'])) { $parts[] = __('Sending your catalog to relaybell in the background — it starts within a minute or two (a little later on a quiet site).', 'relaybell'); } else { /* translators: %s: number of items */ $parts[] = sprintf(__('Sending your catalog to relaybell in the background… %s items so far.', 'relaybell'), number_format_i18n((int) $st['sent'])); } } elseif (!empty($st['last_full']['at'])) { $parts[] = sprintf( /* translators: 1: number of items, 2: time span, e.g. "5 mins" */ _n('Up to date: %1$s item sent to relaybell, last full sync %2$s ago.', 'Up to date: %1$s items sent to relaybell, last full sync %2$s ago.', (int) $st['last_full']['sent'], 'relaybell'), number_format_i18n((int) $st['last_full']['sent']), human_time_diff((int) $st['last_full']['at'], time()) ); $full = isset($st['last_full']['full']) ? (int) $st['last_full']['full'] : 0; $other = (isset($st['last_full']['skipped']) ? (int) $st['last_full']['skipped'] : 0) - $full; if ($other > 0) { /* translators: %s: number of items */ $parts[] = sprintf(__('relaybell skipped %s of them.', 'relaybell'), number_format_i18n($other)); } if ($full > 0) { $state = 'warn'; /* translators: %s: number of items */ $parts[] = sprintf(__('%s of them didn’t fit: your relaybell catalog is full. Leave out what you don’t need to recommend (for example with the relaybell_catalog_item filter).', 'relaybell'), number_format_i18n($full)); } } else { $state = 'info'; $parts[] = __('Not sent yet. Click “Sync now” to send your catalog.', 'relaybell'); } if (!empty($st['live_at'])) { /* translators: %s: time span, e.g. "5 mins" */ $parts[] = sprintf(__('Your latest edits were sent %s ago.', 'relaybell'), human_time_diff((int) $st['live_at'], time())); } if (!empty($st['error']['text']) && (!empty($st['errors']) || !empty($st['live_errors']))) { $state = 'warn'; $parts[] = sprintf( /* translators: 1: time span, 2: what went wrong */ __('The last attempt, %1$s ago, failed: %2$s It tries again automatically.', 'relaybell'), human_time_diff((int) $st['error']['at'], time()), (string) $st['error']['text'] ); } return array('state' => $state, 'text' => implode(' ', $parts), 'running' => !empty($st['running'])); } /** * The past-orders import's status in plain words. * * @return array{state:string,text:string,running:bool} */ function relaybell_import_status() { $st = relaybell_import_state(); if (!empty($st['running'])) { $text = sprintf( /* translators: 1: number of orders, 2: number of orders */ __('Importing in the background… %1$s orders checked so far, %2$s matched to subscribers.', 'relaybell'), number_format_i18n((int) $st['checked']), number_format_i18n((int) $st['imported']) ); if (!empty($st['error']['text']) && !empty($st['errors'])) { /* translators: %s: what went wrong */ return array('state' => 'warn', 'text' => $text . ' ' . sprintf(__('The last attempt failed: %s It tries again automatically.', 'relaybell'), (string) $st['error']['text']), 'running' => true); } return array('state' => 'info', 'text' => $text, 'running' => true); } if (!empty($st['finished'])) { $parts = array( sprintf( /* translators: 1: time span, 2: number of orders */ _n('Last import finished %1$s ago: %2$s past order of your subscribers added.', 'Last import finished %1$s ago: %2$s past orders of your subscribers added.', (int) $st['imported'], 'relaybell'), human_time_diff((int) $st['finished'], time()), number_format_i18n((int) $st['imported']) ), ); if (!empty($st['already'])) { /* translators: %s: number of orders */ $parts[] = sprintf(_n('%s was already in relaybell.', '%s were already in relaybell.', (int) $st['already'], 'relaybell'), number_format_i18n((int) $st['already'])); } if (!empty($st['skipped'])) { /* translators: %s: number of orders */ $parts[] = sprintf(_n('%s was left out: its customer doesn’t get your notifications.', '%s were left out: their customers don’t get your notifications.', (int) $st['skipped'], 'relaybell'), number_format_i18n((int) $st['skipped'])); } if (!empty($st['old'])) { /* translators: %s: number of orders */ $parts[] = sprintf(_n('%s was older than the activity relaybell keeps for your website.', '%s were older than the activity relaybell keeps for your website.', (int) $st['old'], 'relaybell'), number_format_i18n((int) $st['old'])); } return array('state' => 'ok', 'text' => implode(' ', $parts), 'running' => false); } return array('state' => 'info', 'text' => __('Not imported yet.', 'relaybell'), 'running' => false); } /** * The "Personalized recommendations" card on Settings → relaybell (connected sites). * * @param string $msg The page's rb_msg flag. * @return void */ function relaybell_catalog_render_card($msg) { $catalog = relaybell_catalog_status(); $woo = relaybell_import_available(); $linked = get_option('relaybell_identify', '1') === '1'; ?>

' . esc_html__('Full sync started. It runs in the background — you can leave this page.', 'relaybell') . '

'; } elseif ($msg === 'sync_off') { echo '

' . esc_html__('Nothing to sync: tick “Send my products” or “Send my posts” below and save first.', 'relaybell') . '

'; } elseif ($msg === 'import') { echo '

' . esc_html__('Import started. It runs in the background — you can leave this page.', 'relaybell') . '

'; } elseif ($msg === 'import_off') { echo '

' . esc_html__('The import couldn’t start: turn on “Link subscribers to their account email” below first.', 'relaybell') . '

'; } ?>

post_status !== 'publish' || (string) $post->post_password !== '') { return; } if (!in_array($post->post_type, relaybell_catalog_post_types(), true)) { return; } /** This filter is documented in catalog.php (relaybell_catalog_post_item()). */ $type = apply_filters('relaybell_catalog_item_type', 'article', $post); $tax = relaybell_catalog_taxonomies($post->post_type); $data = array( 'itemId' => (string) $post->ID, 'title' => relaybell_catalog_text(get_the_title($post), 300), 'type' => in_array($type, array('article', 'course', 'listing', 'product', 'other'), true) ? $type : 'article', 'category' => relaybell_catalog_category_path($post->ID, $tax['cat']), 'tags' => relaybell_catalog_term_names($post->ID, $tax['tag'], 10), 'url' => relaybell_catalog_url(get_permalink($post)), ); wp_enqueue_script('relaybell-content', plugins_url('assets/relaybell-content.js', __FILE__), array('relaybell-sdk'), RELAYBELL_VERSION, true); wp_add_inline_script('relaybell-content', 'window.relaybellContent = ' . wp_json_encode($data) . ';', 'before'); } add_action('wp_enqueue_scripts', 'relaybell_content_script', 20);