$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') . '
' . esc_html__('Nothing to sync: tick “Send my products” or “Send my posts” below and save first.', 'relaybell') . '
' . esc_html__('Import started. It runs in the background — you can leave this page.', 'relaybell') . '
' . esc_html__('The import couldn’t start: turn on “Link subscribers to their account email” below first.', 'relaybell') . '