From 4505f58c7bc3b1c566f7c2843d769afcb6450145 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 12:58:40 +0900 Subject: [PATCH 1/8] Add a WooCommerce add-to-cart host action for chat. Confirmed add_to_cart events write to the visitor cart. Variable products send nested colour then size choices in one reply, and product pages pass product_id as chat context. Co-authored-by: Cursor --- datalumo.php | 8 +- readme.txt | 74 ++- resources/js/add-to-cart.js | 140 +++++ src/Embed/Embed.php | 53 +- src/Integration/AddToCart.php | 891 ++++++++++++++++++++++++++++++++ src/Integration/WooCommerce.php | 17 +- tests/Unit/AddToCartTest.php | 466 +++++++++++++++++ tests/Unit/EmbedTest.php | 29 ++ 8 files changed, 1664 insertions(+), 14 deletions(-) create mode 100644 resources/js/add-to-cart.js create mode 100644 src/Integration/AddToCart.php create mode 100644 tests/Unit/AddToCartTest.php create mode 100644 tests/Unit/EmbedTest.php diff --git a/datalumo.php b/datalumo.php index 1ac81ff..0c3c671 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,11 +7,11 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.7 + * Version: 0.0.14 * Requires at least: 6.0 * Requires PHP: 8.1 - * Author: Jeffrey van Rossum - * Author URI: https://vanrossum.dev + * Author: Datalumo + * Author URI: https://datalumo.app * Text Domain: datalumo * Domain Path: /languages * License: GPL-2.0-or-later @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.7'); + define('DATALUMO_VERSION', '0.0.14'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index 5d80067..fb98601 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === Datalumo === -Contributors: jeffreyvanrossum +Contributors: datalumo, jeffreyvr Tags: search, ai, chatbot, rag Requires at least: 6.0 -Tested up to: 6.9 +Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.7 +Stable tag: 0.0.14 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,6 +19,32 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. +* **WooCommerce add to cart**: when WooCommerce is active, a confirmed chat action named `add_to_cart` adds the product to the visitor's cart. + +This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. + += External services = + +The plugin talks to the Datalumo instance you configure. The default host is `https://datalumo.app`; you can point it at your own instance instead. + +* Service: [https://datalumo.app](https://datalumo.app) +* Documentation: [https://datalumo.app/docs](https://datalumo.app/docs) +* Terms of Service: [https://datalumo.app/terms](https://datalumo.app/terms) +* Privacy Policy: [https://datalumo.app/privacy](https://datalumo.app/privacy) + +When a feature is enabled, the plugin may: + +* Load the widget script from `{your Datalumo URL}/widget/v1/datalumo.js` (chat, search box, AI summary, and click tracking). +* Send published post content to the Datalumo API so it can be indexed (title, HTML, permalink, author display name, categories, tags, dates, and any custom field mappings you add). +* Send visitor search queries to Datalumo when enhanced search is on. +* Send result-click events (URL, rank, search session) when enhanced search is on. +* Send a signed visitor id (`wp-user-{id}` HMAC) when visitor identity is enabled on the Chatbot tab. + += Development = + +Source code: [https://github.com/datalumo/wordpress](https://github.com/datalumo/wordpress) + +Production dependencies are installed with `composer install --no-dev`. == Installation == @@ -28,8 +54,48 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y 4. Pick which post types sync to which source, and run the first full sync. 5. Add a widget key to enable the chat, search box, or enhanced search. +== Frequently Asked Questions == + += Do I need a Datalumo account? = + +Yes. The plugin does not search or chat on its own. It connects WordPress to a Datalumo instance — the hosted service at datalumo.app or one you run yourself. + += What data is sent to Datalumo? = + +Only after you connect and enable a feature. Content sync sends the published posts you choose. Enhanced search sends visitor queries and optional result-click analytics. The chat and search widgets load Datalumo's script and talk to that instance. Visitor identity is off unless you turn it on. + += Can I use a self-hosted Datalumo instance? = + +Yes. Set the Datalumo URL on the Connection tab before you connect. + += Can the chatbot add products to a WooCommerce cart? = + +Yes, if WooCommerce is active. In Datalumo, add an Act on the page action named add_to_cart with a product_id field (the WordPress product id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. + + == Changelog == += 0.0.14 = +* WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. + += 0.0.13 = +* WooCommerce: variable products ask for colour, then size, then add the matching variation. + += 0.0.12 = +* WooCommerce: add-to-cart sends the variation's size and colour attributes, not only the variation id. + += 0.0.11 = +* Chat on a product page now sends that product id as page context, and add-to-cart uses the current product when the payload has no id. + += 0.0.10 = +* WooCommerce: a variable product asks the visitor to pick a variation in chat (up to 8). More than that links to the product page. + += 0.0.9 = +* WooCommerce: settle an add-to-cart chip through respond() only, so the success line is not stamped on an older message. + += 0.0.8 = +* WooCommerce: a confirmed chat action named add_to_cart adds the product to the visitor's cart. + = 0.0.7 = * Full sync: when the push completes, the plugin now tells Datalumo to start indexing right away instead of waiting for its next background sweep. Best-effort — older Datalumo versions without the endpoint are unaffected. @@ -57,5 +123,5 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y = 0.0.1 = * Initial public release. * Content sync, chat/search widgets, enhanced search, and visitor identity. -* GitHub auto-updates via Plugin Update Checker. +* GitHub auto-updates via Plugin Update Checker (removed ahead of the WordPress.org release). * Dutch and Chinese translations. diff --git a/resources/js/add-to-cart.js b/resources/js/add-to-cart.js new file mode 100644 index 0000000..7259383 --- /dev/null +++ b/resources/js/add-to-cart.js @@ -0,0 +1,140 @@ +/** + * Handles Datalumo host actions named add_to_cart: POST to WordPress so + * WooCommerce can add the item to this visitor's cart, then settle the chip. + */ +(function () { + 'use strict'; + + var config = window.datalumoAddToCart; + + if (! config) { + return; + } + + var eventName = config.event || 'add_to_cart'; + + window.addEventListener('datalumo:action', function (event) { + var detail = event.detail || {}; + + if (detail.name !== eventName) { + return; + } + + event.preventDefault(); + + var payload = withPageProduct( + detail.payload && typeof detail.payload === 'object' && ! Array.isArray(detail.payload) + ? detail.payload + : {}, + ); + + var body = new FormData(); + body.append('action', 'datalumo_add_to_cart'); + body.append('_ajax_nonce', config.nonce); + body.append('payload', JSON.stringify(payload)); + + fetch(config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + }) + .then(function (response) { + return response.json(); + }) + .then(function (json) { + var ok = Boolean(json && json.success); + var data = (json && json.data) || {}; + var message = typeof data.message === 'string' && data.message + ? data.message + : ((config.i18n && (ok ? '' : config.i18n.failed)) || ''); + + finish(detail, { + ok: ok, + message: message, + status: data.status, + layout: data.layout, + choices: data.choices, + url: data.url, + }); + + if (ok) { + refreshCart(data.fragments || {}, data.cart_hash || ''); + } + }) + .catch(function () { + finish(detail, { + ok: false, + message: (config.i18n && config.i18n.failed) || '', + }); + }); + }); + + function withPageProduct(payload) { + var next = {}; + var key; + + for (key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + next[key] = payload[key]; + } + } + + if (! hasProductId(next) && config.productId) { + next.product_id = String(config.productId); + } + + if (! next.page_url && typeof window.location === 'object' && window.location.href) { + next.page_url = window.location.href; + } + + return next; + } + + function hasProductId(payload) { + var keys = ['product_id', 'product', 'id', 'external_id']; + var i; + var value; + + for (i = 0; i < keys.length; i++) { + value = parseInt(payload[keys[i]], 10); + + if (value > 0) { + return true; + } + } + + return Boolean(payload.sku && String(payload.sku).trim()); + } + + function finish(detail, result) { + if (typeof detail.respond === 'function') { + detail.respond(result); + + return; + } + + window.dispatchEvent(new CustomEvent('datalumo:action-result', { + detail: { + answer_id: detail.answer_id, + tool_id: detail.tool_id, + ok: result.ok !== false, + message: result.message || '', + }, + })); + } + + function refreshCart(fragments, cartHash) { + if (typeof window.jQuery === 'undefined') { + return; + } + + var $ = window.jQuery; + + $.each(fragments, function (selector, html) { + $(selector).replaceWith(html); + }); + + $(document.body).trigger('added_to_cart', [fragments, cartHash, null]); + $(document.body).trigger('wc_fragment_refresh'); + } +})(); diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index 1a71620..cbf37f9 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -33,7 +33,7 @@ public function registerScript(): void self::SCRIPT_HANDLE, Options::baseUrl() . '/widget/v1/datalumo.js', [], - null, + DATALUMO_VERSION, ['in_footer' => true], ); } @@ -105,9 +105,58 @@ public function chatShortcode(array|string $attributes = []): string */ private function overrides(): array { + $overrides = []; $user = $this->identity(); + $context = $this->pageContext(); - return $user !== null ? ['user' => $user] : []; + if ($user !== null) { + $overrides['user'] = $user; + } + + if ($context !== []) { + $overrides['context'] = $context; + } + + return $overrides; + } + + /** + * Product page hints for chat. Merged with the widget's automatic + * page_url / page_title so "this product" has a real id to copy. + * + * @return array + */ + public function pageContext(): array + { + $context = []; + + if (function_exists('is_product') && is_product()) { + $id = (int) get_the_ID(); + + if ($id > 0) { + $context['product_id'] = (string) $id; + } + + if ($id > 0 && function_exists('wc_get_product')) { + $product = wc_get_product($id); + + if ($product && method_exists($product, 'get_sku')) { + $sku = trim((string) $product->get_sku()); + + if ($sku !== '') { + $context['sku'] = $sku; + } + } + } + } + + if (function_exists('apply_filters')) { + $filtered = apply_filters('datalumo_chat_context', $context); + + return is_array($filtered) ? $filtered : $context; + } + + return $context; } /** diff --git a/src/Integration/AddToCart.php b/src/Integration/AddToCart.php new file mode 100644 index 0000000..3e12f14 --- /dev/null +++ b/src/Integration/AddToCart.php @@ -0,0 +1,891 @@ + true], + ); + + wp_localize_script('datalumo-add-to-cart', 'datalumoAddToCart', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce(self::NONCE), + 'event' => $this->eventName(), + 'productId' => self::currentProductId(), + 'i18n' => [ + 'missingProduct' => __('No product was specified.', 'datalumo'), + 'failed' => __('Could not add that to the cart.', 'datalumo'), + ], + ]); + } + + public function eventName(): string + { + $event = apply_filters('datalumo_add_to_cart_event', self::DEFAULT_EVENT); + + return is_string($event) && $event !== '' ? $event : self::DEFAULT_EVENT; + } + + public function handle(): void + { + check_ajax_referer(self::NONCE); + + $result = $this->addFromPayload($this->requestPayload()); + + if ($result['ok']) { + wp_send_json_success($result); + } + + wp_send_json_error(array_filter([ + 'message' => $result['message'] ?? '', + 'status' => $result['status'] ?? null, + 'layout' => $result['layout'] ?? null, + 'choices' => $result['choices'] ?? null, + 'url' => $result['url'] ?? null, + ], fn ($value) => $value !== null && $value !== '' && $value !== [])); + } + + /** + * @param array $payload + * @return array{ok: bool, message: string, fragments?: array, cart_hash?: string} + */ + public function addFromPayload(array $payload): array + { + $payload = apply_filters('datalumo_add_to_cart_payload', $payload); + $payload = is_array($payload) ? $payload : []; + + $resolved = self::resolvePayload($payload); + $productId = $resolved['product_id']; + + if ($productId <= 0 && $resolved['sku'] !== '' && function_exists('wc_get_product_id_by_sku')) { + $productId = (int) wc_get_product_id_by_sku($resolved['sku']); + } + + if ($productId <= 0) { + $productId = self::productIdFromPageUrl($payload); + } + + if ($productId <= 0) { + return [ + 'ok' => false, + 'message' => __('No product was specified.', 'datalumo'), + ]; + } + + $variation = self::applyChoices($resolved['variation'], $payload); + + return $this->add($productId, $resolved['quantity'], $resolved['variation_id'], $variation); + } + + /** + * @param array $payload + * @return array{product_id: int, quantity: int, variation_id: int, sku: string, variation: array} + */ + public static function resolvePayload(array $payload): array + { + $quantity = self::firstPositiveInt($payload, ['quantity', 'qty']); + + return [ + 'product_id' => self::firstPositiveInt($payload, ['product_id', 'product', 'id', 'external_id']), + 'quantity' => $quantity > 0 ? min($quantity, 99) : 1, + 'variation_id' => self::firstPositiveInt($payload, ['choice', 'variation_id', 'variation']), + 'sku' => self::firstString($payload, ['sku']), + 'variation' => self::variationAttributes($payload), + ]; + } + + public static function currentProductId(): int + { + if (! function_exists('is_product') || ! is_product()) { + return 0; + } + + return (int) get_the_ID(); + } + + /** + * Merge attributes stored on a variation product into the payload map. + * + * @param array $variation + * @return array + */ + public static function attributesForVariation(int $variationId, array $variation): array + { + if ($variationId <= 0 || ! function_exists('wc_get_product')) { + return $variation; + } + + $product = wc_get_product($variationId); + + if (! $product || ! method_exists($product, 'get_variation_attributes')) { + return $variation; + } + + $fromProduct = $product->get_variation_attributes(); + $merged = []; + + foreach (is_array($fromProduct) ? $fromProduct : [] as $key => $value) { + if (! is_string($key) || ! is_scalar($value)) { + continue; + } + + $value = trim((string) $value); + + if ($value !== '') { + $merged[$key] = $value; + } + } + + foreach ($variation as $key => $value) { + if (! is_string($key) || ! is_scalar($value)) { + continue; + } + + $value = trim((string) $value); + + if ($value !== '') { + $merged[$key] = $value; + } + } + + return $merged; + } + + /** + * @param array $payload + */ + public static function productIdFromPageUrl(array $payload): int + { + $url = self::firstString($payload, ['page_url', 'url']); + + if ($url === '' || ! function_exists('url_to_postid')) { + return 0; + } + + $id = (int) url_to_postid($url); + + if ($id <= 0 || ! function_exists('wc_get_product')) { + return 0; + } + + return wc_get_product($id) ? $id : 0; + } + + /** + * @param array $variation + * @return array{ok: bool, message: string, fragments?: array, cart_hash?: string} + */ + public function add(int $productId, int $quantity, int $variationId = 0, array $variation = []): array + { + if (! function_exists('wc_get_product') || ! function_exists('WC')) { + return [ + 'ok' => false, + 'message' => __('WooCommerce is not available.', 'datalumo'), + ]; + } + + $parent = wc_get_product($productId); + + if (! $parent) { + return [ + 'ok' => false, + 'message' => __('That product could not be found.', 'datalumo'), + ]; + } + + if (method_exists($parent, 'is_type') && $parent->is_type('variable')) { + $resolved = $this->resolveVariableSelection($parent, $variationId, $variation); + + if (array_key_exists('ok', $resolved)) { + return $resolved; + } + + $variationId = $resolved['variation_id']; + $variation = $resolved['variation']; + } + + $product = wc_get_product($variationId > 0 ? $variationId : $productId); + + if (! $product) { + return [ + 'ok' => false, + 'message' => __('That product could not be found.', 'datalumo'), + ]; + } + + if (method_exists($product, 'is_purchasable') && ! $product->is_purchasable()) { + return [ + 'ok' => false, + 'message' => __('That product cannot be purchased.', 'datalumo'), + ]; + } + + $max = method_exists($product, 'get_max_purchase_quantity') + ? (int) $product->get_max_purchase_quantity() + : -1; + + if ($max > 0) { + $quantity = min($quantity, $max); + } + + if (function_exists('wc_load_cart') && WC()->cart === null) { + wc_load_cart(); + } + + if (! WC()->cart) { + return [ + 'ok' => false, + 'message' => __('Could not add that to the cart.', 'datalumo'), + ]; + } + + $variation = self::attributesForVariation($variationId, $variation); + + $added = WC()->cart->add_to_cart($productId, $quantity, $variationId, $variation); + + if (! $added) { + return [ + 'ok' => false, + 'message' => $this->cartErrorMessage(), + ]; + } + + $name = method_exists($product, 'get_name') + ? wp_strip_all_tags((string) $product->get_name()) + : ''; + + return [ + 'ok' => true, + 'message' => $name !== '' + ? sprintf(__('%s added to your cart.', 'datalumo'), $name) + : __('Added to your cart.', 'datalumo'), + 'fragments' => $this->cartFragments(), + 'cart_hash' => method_exists(WC()->cart, 'get_cart_hash') + ? (string) WC()->cart->get_cart_hash() + : '', + ]; + } + + /** + * Ask for one missing attribute at a time (colour, then size), then + * match the variation that has every picked value. + * + * @param array $variation + * @return array{variation_id: int, variation: array}|array{ok: false, message: string, status?: string, layout?: string, choices?: array>, url?: string} + */ + private function resolveVariableSelection(object $parent, int $variationId, array $variation): array + { + $rows = method_exists($parent, 'get_available_variations') + ? $parent->get_available_variations() + : []; + $rows = is_array($rows) ? $rows : []; + $variation = self::attributesForVariation($variationId, $variation); + $missing = self::missingAttributeKeys( + self::variationAttributeKeys($parent, $rows), + $variation, + ); + + if ($missing !== []) { + return $this->elicitAttributes($parent, $rows, $missing, $variation); + } + + if ($variationId <= 0) { + $variationId = self::matchVariationId($rows, $variation); + } + + if ($variationId <= 0) { + $url = method_exists($parent, 'get_permalink') + ? (string) $parent->get_permalink() + : ''; + + return [ + 'ok' => false, + 'message' => __('This product needs a variation.', 'datalumo'), + 'url' => $url, + ]; + } + + return [ + 'variation_id' => $variationId, + 'variation' => $variation, + ]; + } + + /** + * @param array $rows + * @param array $keys + * @param array $selected + * @return array{ok: false, message: string, status?: string, layout?: string, choices?: array>, url?: string} + */ + private function elicitAttributes(object $product, array $rows, array $keys, array $selected): array + { + $choices = self::choicesTree($rows, $keys, $selected, $product); + $url = method_exists($product, 'get_permalink') + ? (string) $product->get_permalink() + : ''; + + if ($choices === []) { + return [ + 'ok' => false, + 'message' => __('This product needs a variation.', 'datalumo'), + 'url' => $url, + ]; + } + + if (self::choicesExceedMax($choices)) { + return [ + 'ok' => false, + 'message' => __('This product has many options. Open the product to pick one.', 'datalumo'), + 'url' => $url, + ]; + } + + return [ + 'ok' => false, + 'status' => 'choices', + 'message' => sprintf(__('Which %s?', 'datalumo'), self::attributeLabel($keys[0])), + 'layout' => 'options', + 'choices' => $choices, + 'url' => $url, + ]; + } + + /** + * @param array $rows + * @return array + */ + public static function variationAttributeKeys(object $product, array $rows): array + { + if (method_exists($product, 'get_variation_attributes')) { + $attrs = $product->get_variation_attributes(); + + if (is_array($attrs) && $attrs !== []) { + $keys = []; + + foreach (array_keys($attrs) as $key) { + if (is_string($key) && $key !== '') { + $keys[] = self::attributeKey($key); + } + } + + if ($keys !== []) { + return $keys; + } + } + } + + $keys = []; + + foreach ($rows as $row) { + if (! is_array($row) || ! is_array($row['attributes'] ?? null)) { + continue; + } + + foreach (array_keys($row['attributes']) as $key) { + if (is_string($key) && $key !== '') { + $keys[$key] = true; + } + } + } + + return array_keys($keys); + } + + /** + * @param array $needed + * @param array $selected + * @return array + */ + public static function missingAttributeKeys(array $needed, array $selected): array + { + $missing = []; + + foreach ($needed as $key) { + if (trim((string) ($selected[$key] ?? '')) === '') { + $missing[] = $key; + } + } + + return $missing; + } + + /** + * @param array $selected + * @return array + */ + /** + * @param array $payload + * @return array + */ + public static function choicePath(array $payload): array + { + $choice = $payload['choice'] ?? null; + + if (is_array($choice)) { + $path = []; + + foreach ($choice as $item) { + if (is_string($item) && trim($item) !== '') { + $path[] = trim($item); + } + } + + return $path; + } + + if (is_string($choice) && trim($choice) !== '') { + return [trim($choice)]; + } + + return []; + } + + /** + * @param array $selected + * @param array $payload + * @return array + */ + public static function applyChoices(array $selected, array $payload): array + { + foreach (self::choicePath($payload) as $choice) { + $selected = self::applyChoice($selected, $choice); + } + + return $selected; + } + + public static function applyChoice(array $selected, string $choice): array + { + if ($choice === '' || ! str_contains($choice, ':')) { + return $selected; + } + + [$key, $value] = explode(':', $choice, 2); + $key = trim($key); + $value = trim($value); + + if (str_starts_with($key, 'attribute_') && $value !== '') { + $selected[$key] = $value; + } + + return $selected; + } + + /** + * @param array $rows + * @param array $keys + * @param array $selected + * @return array> + */ + public static function choicesTree(array $rows, array $keys, array $selected, object $product): array + { + if ($keys === []) { + return []; + } + + $key = $keys[0]; + $rest = array_slice($keys, 1); + $options = self::optionsForAttribute($rows, $key, $selected, $product); + + if ($rest === []) { + return $options; + } + + $nextMessage = sprintf(__('Which %s?', 'datalumo'), self::attributeLabel($rest[0])); + $tree = []; + + foreach ($options as $option) { + $id = (string) ($option['id'] ?? ''); + $value = str_contains($id, ':') ? trim(explode(':', $id, 2)[1]) : ''; + + if ($value === '') { + continue; + } + + $nested = self::choicesTree($rows, $rest, array_merge($selected, [$key => $value]), $product); + + if ($nested === []) { + continue; + } + + $option['message'] = $nextMessage; + $option['choices'] = $nested; + $tree[] = $option; + } + + return $tree; + } + + /** + * @param array> $choices + */ + public static function choicesExceedMax(array $choices): bool + { + if (count($choices) > self::MAX_CHOICES) { + return true; + } + + foreach ($choices as $choice) { + $nested = $choice['choices'] ?? null; + + if (is_array($nested) && $nested !== [] && self::choicesExceedMax($nested)) { + return true; + } + } + + return false; + } + + /** + * @param array $rows + * @param array $selected + * @return array + */ + public static function optionsForAttribute(array $rows, string $key, array $selected, object $product): array + { + $fromRows = []; + $any = false; + + foreach ($rows as $row) { + if (! is_array($row) || ! self::isAvailableVariation($row)) { + continue; + } + + if (! self::variationMatches($row, $selected)) { + continue; + } + + $value = trim((string) ($row['attributes'][$key] ?? '')); + + if ($value === '') { + $any = true; + + continue; + } + + $fromRows[$value] = self::optionLabel($value); + } + + if ($any || $fromRows === []) { + foreach (self::parentAttributeOptions($product, $key) as $slug => $label) { + $fromRows[$slug] = $label; + } + } + + $choices = []; + + foreach ($fromRows as $slug => $label) { + $choices[] = [ + 'id' => $key.':'.$slug, + 'label' => $label, + ]; + } + + return $choices; + } + + /** + * @param array $rows + * @param array $selected + */ + public static function matchVariationId(array $rows, array $selected): int + { + foreach ($rows as $row) { + if (! is_array($row) || ! self::isAvailableVariation($row)) { + continue; + } + + if (! self::variationMatches($row, $selected)) { + continue; + } + + return (int) $row['variation_id']; + } + + return 0; + } + + /** + * @param array $row + * @param array $selected + */ + public static function variationMatches(array $row, array $selected): bool + { + $attrs = is_array($row['attributes'] ?? null) ? $row['attributes'] : []; + + foreach ($selected as $key => $value) { + if (! is_string($key) || trim((string) $value) === '') { + continue; + } + + $rowValue = trim((string) ($attrs[$key] ?? '')); + + if ($rowValue !== '' && $rowValue !== trim((string) $value)) { + return false; + } + } + + return true; + } + + public static function attributeKey(string $name): string + { + return str_starts_with($name, 'attribute_') ? $name : 'attribute_'.$name; + } + + public static function attributeLabel(string $key): string + { + $name = str_starts_with($key, 'attribute_') ? substr($key, 10) : $key; + + if (function_exists('wc_attribute_label')) { + $label = wc_attribute_label($name); + + if (is_string($label) && trim($label) !== '') { + return trim($label); + } + } + + if (str_starts_with($name, 'pa_')) { + $name = substr($name, 3); + } + + return self::optionLabel($name); + } + + public static function optionLabel(string $value): string + { + return ucfirst(str_replace(['-', '_'], ' ', $value)); + } + + /** + * @param array $row + */ + private static function isAvailableVariation(array $row): bool + { + if ((int) ($row['variation_id'] ?? 0) <= 0) { + return false; + } + + if (array_key_exists('is_in_stock', $row) && ! $row['is_in_stock']) { + return false; + } + + if (array_key_exists('is_purchasable', $row) && ! $row['is_purchasable']) { + return false; + } + + return true; + } + + /** + * @return array + */ + private static function parentAttributeOptions(object $product, string $key): array + { + if (! method_exists($product, 'get_variation_attributes')) { + return []; + } + + $attrs = $product->get_variation_attributes(); + + if (! is_array($attrs)) { + return []; + } + + $raw = $attrs[$key] ?? $attrs[self::unprefixedAttributeKey($key)] ?? null; + + if (! is_array($raw)) { + return []; + } + + $options = []; + + foreach ($raw as $index => $value) { + if (is_int($index) || (is_string($index) && ctype_digit((string) $index))) { + $slug = trim((string) $value); + + if ($slug !== '') { + $options[$slug] = self::optionLabel($slug); + } + + continue; + } + + $slug = trim((string) $index); + $label = is_scalar($value) ? trim((string) $value) : ''; + + if ($slug !== '') { + $options[$slug] = $label !== '' ? $label : self::optionLabel($slug); + } + } + + return $options; + } + + private static function unprefixedAttributeKey(string $key): string + { + return str_starts_with($key, 'attribute_') ? substr($key, 10) : $key; + } + + /** + * @return array + */ + private function requestPayload(): array + { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in handle(). + $raw = wp_unslash($_POST['payload'] ?? ''); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + if (is_string($raw) && $raw !== '') { + $decoded = json_decode($raw, true); + + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } + + /** + * @return array + */ + private function cartFragments(): array + { + if (! function_exists('woocommerce_mini_cart')) { + return []; + } + + ob_start(); + woocommerce_mini_cart(); + $miniCart = (string) ob_get_clean(); + + $fragments = apply_filters('woocommerce_add_to_cart_fragments', [ + 'div.widget_shopping_cart_content' => '
' . $miniCart . '
', + ]); + + return is_array($fragments) ? $fragments : []; + } + + private function cartErrorMessage(): string + { + if (function_exists('wc_get_notices')) { + $notices = wc_get_notices('error'); + + if (function_exists('wc_clear_notices')) { + wc_clear_notices(); + } + + $first = is_array($notices) ? ($notices[0]['notice'] ?? '') : ''; + + if (is_string($first) && $first !== '') { + return wp_strip_all_tags($first); + } + } + + return __('Could not add that to the cart.', 'datalumo'); + } + + /** + * @param array $payload + * @param array $keys + */ + private static function firstPositiveInt(array $payload, array $keys): int + { + foreach ($keys as $key) { + if (! array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_float($value) && $value > 0 && $value === floor($value)) { + return (int) $value; + } + + if (is_string($value)) { + $value = trim($value); + + if ($value !== '' && ctype_digit($value)) { + return (int) $value; + } + } + } + + return 0; + } + + /** + * @param array $payload + * @param array $keys + */ + private static function firstString(array $payload, array $keys): string + { + foreach ($keys as $key) { + if (! array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return ''; + } + + /** + * @param array $payload + * @return array + */ + private static function variationAttributes(array $payload): array + { + $attributes = []; + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! str_starts_with($key, 'attribute_')) { + continue; + } + + if (! is_scalar($value)) { + continue; + } + + $attributes[$key] = (string) $value; + } + + return $attributes; + } +} diff --git a/src/Integration/WooCommerce.php b/src/Integration/WooCommerce.php index 1894c68..4767637 100644 --- a/src/Integration/WooCommerce.php +++ b/src/Integration/WooCommerce.php @@ -6,8 +6,9 @@ /** * WooCommerce search behaviour on the interceptor's hooks: catalog sorts, - * hidden-product exclusion, and the price-filter widget. Registered only - * when WooCommerce is active. + * hidden-product exclusion, and the price-filter widget. Also registers + * add-to-cart for Datalumo host actions. Registered only when WooCommerce + * is active. */ class WooCommerce { @@ -20,6 +21,8 @@ public function register(): void { add_filter('datalumo_sort_map', [$this, 'sortMap']); add_filter('datalumo_resolve_args', [$this, 'resolveArgs'], 10, 2); + + (new AddToCart())->register(); } /** @@ -90,8 +93,13 @@ private function excludeHidden(array $args): array */ private function applyPriceFilter(array $args): array { - $min = isset($_GET['min_price']) && is_numeric($_GET['min_price']) ? (float) $_GET['min_price'] : null; - $max = isset($_GET['max_price']) && is_numeric($_GET['max_price']) ? (float) $_GET['max_price'] : null; + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public WooCommerce price-filter query args. + $minRaw = isset($_GET['min_price']) ? sanitize_text_field(wp_unslash($_GET['min_price'])) : ''; + $maxRaw = isset($_GET['max_price']) ? sanitize_text_field(wp_unslash($_GET['max_price'])) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $min = $minRaw !== '' && is_numeric($minRaw) ? (float) $minRaw : null; + $max = $maxRaw !== '' && is_numeric($maxRaw) ? (float) $maxRaw : null; if ($min === null && $max === null) { return $args; @@ -106,6 +114,7 @@ private function applyPriceFilter(array $args): array } $existing = $args['meta_query'] ?? []; + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- WooCommerce price filter on the result pool. $args['meta_query'] = $existing ? ['relation' => 'AND', $existing, $clause] : [$clause]; return $args; diff --git a/tests/Unit/AddToCartTest.php b/tests/Unit/AddToCartTest.php new file mode 100644 index 0000000..5520135 --- /dev/null +++ b/tests/Unit/AddToCartTest.php @@ -0,0 +1,466 @@ +register(); + + expect(Actions\has('wp_enqueue_scripts'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_datalumo_add_to_cart'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_add_to_cart'))->not->toBeFalse(); +}); + +it('registers add-to-cart when the WooCommerce integration boots', function () { + (new WooCommerce())->register(); + + expect(Actions\has('wp_ajax_datalumo_add_to_cart'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_add_to_cart'))->not->toBeFalse(); +}); + +it('localises the frontend script with the event name and nonce', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('is_product')->justReturn(false); + Functions\when('admin_url')->justReturn('https://example.com/wp-admin/admin-ajax.php'); + Functions\when('wp_create_nonce')->justReturn('test-nonce'); + Functions\when('wp_enqueue_script')->justReturn(true); + + $localised = null; + Functions\when('wp_localize_script')->alias(function ($handle, $name, $data) use (&$localised) { + $localised = ['handle' => $handle, 'name' => $name, 'data' => $data]; + + return true; + }); + + (new AddToCart())->enqueue(); + + expect($localised['handle'])->toBe('datalumo-add-to-cart') + ->and($localised['name'])->toBe('datalumoAddToCart') + ->and($localised['data']['event'])->toBe('add_to_cart') + ->and($localised['data']['nonce'])->toBe('test-nonce') + ->and($localised['data']['productId'])->toBe(0) + ->and($localised['data']['ajaxUrl'])->toBe('https://example.com/wp-admin/admin-ajax.php'); +}); + +it('localises the current product id on a product page', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('is_product')->justReturn(true); + Functions\when('get_the_ID')->justReturn(1842); + Functions\when('admin_url')->justReturn('https://example.com/wp-admin/admin-ajax.php'); + Functions\when('wp_create_nonce')->justReturn('test-nonce'); + Functions\when('wp_enqueue_script')->justReturn(true); + + $localised = null; + Functions\when('wp_localize_script')->alias(function ($handle, $name, $data) use (&$localised) { + $localised = $data; + + return true; + }); + + (new AddToCart())->enqueue(); + + expect($localised['productId'])->toBe(1842); +}); + +it('reads product_id aliases and defaults quantity to 1', function (array $payload, array $expected) { + expect(AddToCart::resolvePayload($payload))->toMatchArray($expected); +})->with([ + 'product_id string' => [ + ['product_id' => '1842'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'external_id' => [ + ['external_id' => '99', 'qty' => '3'], + ['product_id' => 99, 'quantity' => 3, 'variation_id' => 0, 'sku' => ''], + ], + 'id + sku + variation' => [ + ['id' => 12, 'sku' => 'MUG-BLU', 'variation_id' => 44, 'quantity' => 2], + ['product_id' => 12, 'quantity' => 2, 'variation_id' => 44, 'sku' => 'MUG-BLU'], + ], + 'choice is the variation id' => [ + ['product_id' => 1842, 'choice' => '99'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 99, 'sku' => ''], + ], + 'encoded attribute choice is not a variation id' => [ + ['product_id' => 1842, 'choice' => 'attribute_pa_color:blue'], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'choice path is not a variation id' => [ + ['product_id' => 1842, 'choice' => ['attribute_pa_color:blue', 'attribute_pa_size:small']], + ['product_id' => 1842, 'quantity' => 1, 'variation_id' => 0, 'sku' => ''], + ], + 'caps quantity' => [ + ['product_id' => 1, 'quantity' => 500], + ['product_id' => 1, 'quantity' => 99], + ], + 'ignores non-numeric id' => [ + ['id' => 'abc', 'sku' => 'SKU-1'], + ['product_id' => 0, 'sku' => 'SKU-1'], + ], +]); + +it('collects variation attributes from the payload', function () { + $resolved = AddToCart::resolvePayload([ + 'product_id' => 10, + 'attribute_pa_color' => 'blue', + 'label' => 'ignored', + ]); + + expect($resolved['variation'])->toBe(['attribute_pa_color' => 'blue']); +}); + +it('fails when no product id or sku can be resolved', function () { + Functions\when('apply_filters')->returnArg(2); + + expect((new AddToCart())->addFromPayload(['label' => 'mug'])) + ->toMatchArray(['ok' => false, 'message' => 'No product was specified.']); +}); + +it('resolves the product from the page url when the payload has no id', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('url_to_postid')->justReturn(1842); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 1842 ? purchasableProduct() : null); + stubCart(fn () => 'item-key'); + + $result = (new AddToCart())->addFromPayload([ + 'page_url' => 'https://store.test/product/v-neck-t-shirt', + ]); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.'); +}); + +it('resolves a sku when product_id is missing', function () { + Functions\when('apply_filters')->returnArg(2); + Functions\when('wc_get_product_id_by_sku')->justReturn(77); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 77 ? purchasableProduct() : null); + stubCart(fn () => 'item-key'); + + $result = (new AddToCart())->addFromPayload(['sku' => 'MUG-BLU']); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.'); +}); + +it('rejects a missing product', function () { + Functions\when('wc_get_product')->justReturn(false); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'That product could not be found.']); +}); + +it('rejects a variable product without a variation', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct(variable: true)); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'This product needs a variation.']); +}); + +it('asks for colour then size then adds the matching variation', function () { + $parent = purchasableProduct( + variable: true, + variations: [ + [ + 'variation_id' => 11, + 'attributes' => ['attribute_pa_color' => 'blue', 'attribute_pa_size' => 'small'], + 'is_in_stock' => true, + 'is_purchasable' => true, + ], + [ + 'variation_id' => 12, + 'attributes' => ['attribute_pa_color' => 'white', 'attribute_pa_size' => 'small'], + 'is_in_stock' => true, + 'is_purchasable' => true, + ], + ], + permalink: 'https://store.test/vneck', + attributeOptions: [ + 'pa_color' => ['blue', 'white'], + 'pa_size' => ['small', 'large'], + ], + ); + + Functions\when('apply_filters')->returnArg(2); + Functions\when('wc_get_product')->alias(fn ($id) => (int) $id === 1842 ? $parent : purchasableProduct()); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + $first = (new AddToCart())->addFromPayload(['product_id' => 1842]); + + expect($first)->toMatchArray([ + 'ok' => false, + 'status' => 'choices', + 'layout' => 'options', + 'message' => 'Which Color?', + 'url' => 'https://store.test/vneck', + 'choices' => [ + [ + 'id' => 'attribute_pa_color:blue', + 'label' => 'Blue', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ], + [ + 'id' => 'attribute_pa_color:white', + 'label' => 'White', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ], + ], + ]); + + $second = (new AddToCart())->addFromPayload([ + 'product_id' => 1842, + 'choice' => 'attribute_pa_color:blue', + ]); + + expect($second)->toMatchArray([ + 'ok' => false, + 'status' => 'choices', + 'message' => 'Which Size?', + 'choices' => [ + ['id' => 'attribute_pa_size:small', 'label' => 'Small'], + ], + ]); + + $seen = null; + stubCart(function ($productId, $quantity, $variationId, $variation) use (&$seen) { + $seen = [$productId, $quantity, $variationId, $variation]; + + return 'item-key'; + }); + + $added = (new AddToCart())->addFromPayload([ + 'product_id' => 1842, + 'choice' => ['attribute_pa_color:blue', 'attribute_pa_size:small'], + ]); + + expect($added['ok'])->toBeTrue() + ->and($seen)->toBe([ + 1842, + 1, + 11, + [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'small', + ], + ]); +}); + +it('turns a long variation list into a product link', function () { + $rows = []; + + for ($i = 1; $i <= 9; $i++) { + $rows[] = [ + 'variation_id' => $i, + 'attributes' => ['attribute_pa_size' => 's'.$i], + 'is_in_stock' => true, + 'is_purchasable' => true, + ]; + } + + Functions\when('wc_get_product')->justReturn(purchasableProduct( + variable: true, + variations: $rows, + permalink: 'https://store.test/vneck', + )); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1))->toMatchArray([ + 'ok' => false, + 'message' => 'This product has many options. Open the product to pick one.', + 'url' => 'https://store.test/vneck', + ]); +}); + +it('rejects a product that is not purchasable', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct(purchasable: false)); + Functions\when('WC')->justReturn((object) ['cart' => (object) []]); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'That product cannot be purchased.']); +}); + +it('fills variation attributes from the variation product', function () { + Functions\when('wc_get_product')->alias(function ($id) { + if ((int) $id === 99) { + return new class + { + public function is_purchasable(): bool + { + return true; + } + + public function is_type(string $type): bool + { + return $type === 'variation'; + } + + public function get_name(): string + { + return 'V-Neck T-Shirt - Blue, Large'; + } + + public function get_max_purchase_quantity(): int + { + return -1; + } + + public function get_variation_attributes(): array + { + return [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'large', + ]; + } + }; + } + + return purchasableProduct(variable: true); + }); + + $seen = null; + stubCart(function ($productId, $quantity, $variationId, $variation) use (&$seen) { + $seen = [$productId, $quantity, $variationId, $variation]; + + return 'item-key'; + }); + Functions\when('wp_strip_all_tags')->returnArg(1); + + expect((new AddToCart())->add(1842, 1, 99)['ok'])->toBeTrue() + ->and($seen)->toBe([ + 1842, + 1, + 99, + [ + 'attribute_pa_color' => 'blue', + 'attribute_pa_size' => 'large', + ], + ]); +}); + +it('adds a simple product to the cart', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct()); + stubCart(fn ($productId, $quantity, $variationId, $variation) => ( + $productId === 1842 && $quantity === 2 && $variationId === 0 && $variation === [] + ? 'item-key' + : false + )); + + $result = (new AddToCart())->add(1842, 2); + + expect($result['ok'])->toBeTrue() + ->and($result['message'])->toBe('Blue mug added to your cart.') + ->and($result['cart_hash'])->toBe('cart-hash') + ->and($result['fragments'])->toBe([]); +}); + +it('surfaces the WooCommerce notice when add_to_cart fails', function () { + Functions\when('wc_get_product')->justReturn(purchasableProduct()); + stubCart(fn () => false); + Functions\when('wc_get_notices')->justReturn([['notice' => 'Out of stock.']]); + Functions\when('wc_clear_notices')->justReturn(true); + + expect((new AddToCart())->add(1842, 1)) + ->toMatchArray(['ok' => false, 'message' => 'Out of stock.']); +}); + +function purchasableProduct( + bool $purchasable = true, + bool $variable = false, + array $variations = [], + string $permalink = '', + array $attributeOptions = [], +): object { + return new class($purchasable, $variable, $variations, $permalink, $attributeOptions) + { + /** + * @param array $variations + * @param array $attributeOptions + */ + public function __construct( + private bool $purchasable, + private bool $variable, + private array $variations, + private string $permalink, + private array $attributeOptions, + ) {} + + public function is_purchasable(): bool + { + return $this->purchasable; + } + + public function is_type(string $type): bool + { + return $this->variable && $type === 'variable'; + } + + public function get_name(): string + { + return 'Blue mug'; + } + + public function get_max_purchase_quantity(): int + { + return -1; + } + + /** + * @return array + */ + public function get_available_variations(): array + { + return $this->variations; + } + + public function get_permalink(): string + { + return $this->permalink; + } + + /** + * @return array + */ + public function get_variation_attributes(): array + { + return $this->attributeOptions; + } + }; +} + +function stubCart(callable $addToCart): void +{ + $cart = new class($addToCart) + { + public function __construct(private $addToCart) {} + + public function add_to_cart(int $productId, int $quantity = 1, int $variationId = 0, array $variation = []): string|false + { + return ($this->addToCart)($productId, $quantity, $variationId, $variation); + } + + public function get_cart_hash(): string + { + return 'cart-hash'; + } + }; + + Functions\when('WC')->justReturn((object) ['cart' => $cart]); + Functions\when('wp_strip_all_tags')->returnArg(1); +} diff --git a/tests/Unit/EmbedTest.php b/tests/Unit/EmbedTest.php new file mode 100644 index 0000000..e70c389 --- /dev/null +++ b/tests/Unit/EmbedTest.php @@ -0,0 +1,29 @@ +justReturn(false); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([]); +}); + +it('puts the current product id in chat context on a product page', function () { + Functions\when('is_product')->justReturn(true); + Functions\when('get_the_ID')->justReturn(1842); + Functions\when('wc_get_product')->justReturn(new class + { + public function get_sku(): string + { + return 'VNECK-BLU'; + } + }); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([ + 'product_id' => '1842', + 'sku' => 'VNECK-BLU', + ]); +}); From 8e2d746e16cf0c3a251276b8a23639492c607d93 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 13:27:55 +0900 Subject: [PATCH 2/8] Handle view cart, checkout, page, and form host actions. Confirmed chat events open the Woo cart or checkout, a published page, or a form. Product and other singular pages send page_id as context. Co-authored-by: Cursor --- datalumo.php | 4 +- readme.txt | 16 +- resources/js/host-navigation.js | 253 ++++++++++++++++++++++++++ src/Embed/Embed.php | 8 + src/Integration/HostNavigation.php | 278 +++++++++++++++++++++++++++++ src/Plugin.php | 1 + tests/Unit/EmbedTest.php | 16 +- tests/Unit/HostNavigationTest.php | 96 ++++++++++ 8 files changed, 666 insertions(+), 6 deletions(-) create mode 100644 resources/js/host-navigation.js create mode 100644 src/Integration/HostNavigation.php create mode 100644 tests/Unit/HostNavigationTest.php diff --git a/datalumo.php b/datalumo.php index 0c3c671..0a60f9f 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,7 +7,7 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.14 + * Version: 0.0.15 * Requires at least: 6.0 * Requires PHP: 8.1 * Author: Datalumo @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.14'); + define('DATALUMO_VERSION', '0.0.15'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index fb98601..da94b5b 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: search, ai, chatbot, rag Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.14 +Stable tag: 0.0.15 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,7 +19,7 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. -* **WooCommerce add to cart**: when WooCommerce is active, a confirmed chat action named `add_to_cart` adds the product to the visitor's cart. +* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`, `start_form`). Add those actions in Datalumo under Reply → What it can do. This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. @@ -70,11 +70,21 @@ Yes. Set the Datalumo URL on the Connection tab before you connect. = Can the chatbot add products to a WooCommerce cart? = -Yes, if WooCommerce is active. In Datalumo, add an Act on the page action named add_to_cart with a product_id field (the WordPress product id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. +Yes, if WooCommerce is active. In Datalumo, add the WordPress Add to cart action (event add_to_cart, field product_id). The visitor confirms in chat. Variable products send colour, then that colour's sizes, as nested choice steps (up to 8 options per step); more than that opens the product page. + += What other chat actions does the plugin handle? = + +View cart and Open checkout (WooCommerce), Open this page (a post or page by id, slug, or same-site URL), and Start a form (scrolls to a form on this page, or opens the page that has it). Add them from Reply → Plugin actions → WordPress. == Changelog == += 0.0.15 = +* Chat: view_cart and open_checkout open the WooCommerce cart or checkout. +* Chat: open_page opens a published post or page (id, slug, or same-site URL). +* Chat: start_form scrolls to a form on this page, or opens the page that has it. +* Product and other singular pages send page_id as chat context. + = 0.0.14 = * WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js new file mode 100644 index 0000000..857b5c7 --- /dev/null +++ b/resources/js/host-navigation.js @@ -0,0 +1,253 @@ +/** + * Handles Datalumo host actions that open a page: cart, checkout, a + * WordPress post, or a form on this page (or another page). + */ +(function () { + 'use strict'; + + var config = window.datalumoHostNavigation; + + if (! config) { + return; + } + + var events = config.events || ['view_cart', 'open_checkout', 'open_page', 'start_form']; + + window.addEventListener('datalumo:action', function (event) { + var detail = event.detail || {}; + + if (events.indexOf(detail.name) === -1) { + return; + } + + event.preventDefault(); + + var payload = isPlainObject(detail.payload) ? detail.payload : {}; + + if (detail.name === 'view_cart') { + go(detail, config.cartUrl, (config.i18n && config.i18n.no_cart) || ''); + + return; + } + + if (detail.name === 'open_checkout') { + go(detail, config.checkoutUrl, (config.i18n && config.i18n.no_checkout) || ''); + + return; + } + + if (detail.name === 'start_form') { + var form = findForm(payload); + + if (form) { + scrollToForm(form); + finish(detail, { ok: true, message: '' }); + + return; + } + } + + resolveThenGo(detail, payload); + }); + + bootPendingForm(); + + function resolveThenGo(detail, payload) { + var body = new FormData(); + var request = {}; + var key; + + for (key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + request[key] = payload[key]; + } + } + + request.event = detail.name; + body.append('action', 'datalumo_host_navigation'); + body.append('_ajax_nonce', config.nonce); + body.append('payload', JSON.stringify(request)); + + fetch(config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + }) + .then(function (response) { + return response.json(); + }) + .then(function (json) { + var data = (json && json.data) || {}; + var url = typeof data.url === 'string' ? data.url : ''; + + if (json && json.success && isSameSite(url)) { + finish(detail, { ok: true, message: '' }); + window.location.assign(url); + + return; + } + + finish(detail, { + ok: false, + message: (typeof data.message === 'string' && data.message) + || (config.i18n && config.i18n.failed) + || '', + }); + }) + .catch(function () { + finish(detail, { + ok: false, + message: (config.i18n && config.i18n.failed) || '', + }); + }); + } + + function go(detail, url, missingMessage) { + if (! isSameSite(url)) { + finish(detail, { ok: false, message: missingMessage || (config.i18n && config.i18n.failed) || '' }); + + return; + } + + finish(detail, { ok: true, message: '' }); + window.location.assign(url); + } + + function findForm(payload) { + var selector = sanitizeSelector(payload.selector); + var el; + + if (selector) { + el = document.querySelector(selector); + + if (el) { + return el; + } + } + + var id = String(payload.form_id || '').replace(/[^\w-]/g, ''); + + if (id) { + el = firstMatch([ + '#' + id, + '#wpcf7-f' + id, + '#wpforms-' + id, + '#gform_' + id, + '#gform_wrapper_' + id, + ]); + + if (el) { + return el; + } + } + + return document.querySelector( + 'form.wpcf7-form, form.wpforms-form, .gform_wrapper form, form:not([role="search"]):not(.search-form):not(.woocommerce-cart-form):not(.woocommerce-checkout)', + ); + } + + function firstMatch(selectors) { + var i; + var el; + + for (i = 0; i < selectors.length; i++) { + el = document.querySelector(selectors[i]); + + if (el) { + return el; + } + } + + return null; + } + + function sanitizeSelector(value) { + var selector = String(value || '').trim(); + + if (selector === '') { + return ''; + } + + if (/^#?[A-Za-z][\w-]*$/.test(selector)) { + return selector.charAt(0) === '#' ? selector : '#' + selector; + } + + if (/^\.[A-Za-z][\w-]*$/.test(selector)) { + return selector; + } + + if (/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/.test(selector)) { + return selector; + } + + return ''; + } + + function scrollToForm(el) { + if (typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + var focusable = el.matches && el.matches('input, textarea, select') + ? el + : el.querySelector('input:not([type="hidden"]), textarea, select'); + + if (focusable && typeof focusable.focus === 'function') { + focusable.focus({ preventScroll: true }); + } + } + + function bootPendingForm() { + var params = new URLSearchParams(window.location.search); + var pending = params.get('datalumo_form') || ''; + var hash = (window.location.hash || '').replace(/^#/, ''); + var form = findForm({ + form_id: pending || (hash && hash !== 'datalumo-form' ? hash : ''), + }); + + if (form && (pending || hash === 'datalumo-form' || hash)) { + scrollToForm(form); + } + } + + function isSameSite(url) { + if (! url || typeof url !== 'string') { + return false; + } + + try { + var parsed = new URL(url, window.location.href); + var home = String(config.homeHost || window.location.hostname).toLowerCase(); + var host = parsed.hostname.toLowerCase(); + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + return host === home || host === 'www.' + home || 'www.' + host === home; + } catch (error) { + return false; + } + } + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && ! Array.isArray(value); + } + + function finish(detail, result) { + if (typeof detail.respond === 'function') { + detail.respond(result); + + return; + } + + window.dispatchEvent(new CustomEvent('datalumo:action-result', { + detail: { + answer_id: detail.answer_id, + tool_id: detail.tool_id, + ok: result.ok !== false, + message: result.message || '', + }, + })); + } +})(); diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index cbf37f9..8dc20db 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -130,6 +130,14 @@ public function pageContext(): array { $context = []; + if (function_exists('is_singular') && is_singular()) { + $pageId = (int) get_the_ID(); + + if ($pageId > 0) { + $context['page_id'] = (string) $pageId; + } + } + if (function_exists('is_product') && is_product()) { $id = (int) get_the_ID(); diff --git a/src/Integration/HostNavigation.php b/src/Integration/HostNavigation.php new file mode 100644 index 0000000..846815c --- /dev/null +++ b/src/Integration/HostNavigation.php @@ -0,0 +1,278 @@ + true], + ); + + wp_localize_script('datalumo-host-navigation', 'datalumoHostNavigation', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce(self::NONCE), + 'events' => self::EVENTS, + 'cartUrl' => $this->cartUrl(), + 'checkoutUrl' => $this->checkoutUrl(), + 'homeHost' => $this->homeHost(), + 'i18n' => [ + 'failed' => __('Could not open that page.', 'datalumo'), + 'no_page' => __('No page was specified.', 'datalumo'), + 'no_form' => __('No form was found on this page.', 'datalumo'), + 'no_cart' => __('The cart is not available.', 'datalumo'), + 'no_checkout' => __('Checkout is not available.', 'datalumo'), + ], + ]); + } + + public function handle(): void + { + check_ajax_referer(self::NONCE); + + $payload = $this->requestPayload(); + $event = sanitize_key((string) ($payload['event'] ?? '')); + + if (! in_array($event, ['open_page', 'start_form'], true)) { + wp_send_json_error(['message' => __('Unknown action.', 'datalumo')], 400); + } + + $url = $this->resolveUrl($event, $payload); + + if ($url === null) { + wp_send_json_error([ + 'message' => $event === 'start_form' + ? __('No form was found on this page.', 'datalumo') + : __('No page was specified.', 'datalumo'), + ], 422); + } + + wp_send_json_success(['url' => $url]); + } + + /** + * @param array $payload + */ + public function resolveUrl(string $event, array $payload): ?string + { + if ($event === 'view_cart') { + return $this->cartUrl() ?: null; + } + + if ($event === 'open_checkout') { + return $this->checkoutUrl() ?: null; + } + + $url = $this->publishedUrlFromPayload($payload); + + if ($url === null) { + return null; + } + + if ($event === 'start_form') { + return $this->withFormHint($url, $payload); + } + + return $url; + } + + /** + * @param array $payload + */ + public function publishedUrlFromPayload(array $payload): ?string + { + $url = trim((string) ($payload['url'] ?? '')); + + if ($url !== '' && $this->isSameSiteUrl($url)) { + return $url; + } + + $id = $this->positiveId( + $payload['page_id'] ?? $payload['id'] ?? $payload['external_id'] ?? null, + ); + + if ($id !== null) { + $permalink = $this->publishedPermalink($id); + + if ($permalink !== null) { + return $permalink; + } + } + + $slug = trim((string) ($payload['slug'] ?? '')); + + if ($slug !== '' && function_exists('get_page_by_path')) { + $page = get_page_by_path($slug); + + if (is_object($page) && isset($page->ID)) { + return $this->publishedPermalink((int) $page->ID); + } + } + + return null; + } + + public function isSameSiteUrl(string $url): bool + { + $parts = parse_url($url); + + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + + if (! in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = strtolower((string) $parts['host']); + $home = $this->homeHost(); + + if ($home === '' || $host === '') { + return false; + } + + return $host === $home || $host === 'www.' . $home || 'www.' . $host === $home; + } + + public function sanitizeSelector(string $selector): string + { + $selector = trim($selector); + + if ($selector === '') { + return ''; + } + + if (preg_match('/^#?[A-Za-z][\w-]*$/', $selector) === 1) { + return $selector; + } + + if (preg_match('/^\.[A-Za-z][\w-]*$/', $selector) === 1) { + return $selector; + } + + if (preg_match('/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/', $selector) === 1) { + return $selector; + } + + return ''; + } + + public function cartUrl(): string + { + if (! function_exists('wc_get_cart_url')) { + return ''; + } + + $url = (string) wc_get_cart_url(); + + return $this->isSameSiteUrl($url) ? $url : ''; + } + + public function checkoutUrl(): string + { + if (! function_exists('wc_get_checkout_url')) { + return ''; + } + + $url = (string) wc_get_checkout_url(); + + return $this->isSameSiteUrl($url) ? $url : ''; + } + + private function homeHost(): string + { + $home = function_exists('home_url') ? home_url('/') : ''; + $host = parse_url($home, PHP_URL_HOST); + + return is_string($host) ? strtolower($host) : ''; + } + + private function publishedPermalink(int $id): ?string + { + if ($id <= 0 || ! function_exists('get_post') || ! function_exists('get_permalink')) { + return null; + } + + $post = get_post($id); + + if (! is_object($post) || ($post->post_status ?? '') !== 'publish') { + return null; + } + + $permalink = (string) get_permalink($post); + + return $this->isSameSiteUrl($permalink) ? $permalink : null; + } + + private function positiveId(mixed $value): ?int + { + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + $id = (int) $value; + + return $id > 0 ? $id : null; + } + + return null; + } + + /** + * @param array $payload + */ + private function withFormHint(string $url, array $payload): string + { + $formId = preg_replace('/[^\w-]/', '', (string) ($payload['form_id'] ?? '')) ?? ''; + + if ($formId === '') { + return $this->appendHash($url, 'datalumo-form'); + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . 'datalumo_form=' . rawurlencode($formId); + } + + private function appendHash(string $url, string $hash): string + { + $base = preg_replace('/#.*$/', '', $url) ?? $url; + + return $base . '#' . $hash; + } + + /** + * @return array + */ + private function requestPayload(): array + { + $raw = isset($_POST['payload']) ? wp_unslash($_POST['payload']) : ''; + + if (! is_string($raw) || $raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 16fab40..f2585db 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -54,6 +54,7 @@ public function boot(): void (new Summary())->register(); (new ClickTracking())->register(); (new Embed())->register(); + (new Integration\HostNavigation())->register(); add_action('admin_notices', [$this, 'configurationNotice']); } diff --git a/tests/Unit/EmbedTest.php b/tests/Unit/EmbedTest.php index e70c389..9440927 100644 --- a/tests/Unit/EmbedTest.php +++ b/tests/Unit/EmbedTest.php @@ -3,15 +3,28 @@ use Brain\Monkey\Functions; use Datalumo\Wp\Embed\Embed; -it('sends no product context off a product page', function () { +it('sends no page context off a singular view', function () { Functions\when('is_product')->justReturn(false); + Functions\when('is_singular')->justReturn(false); Functions\when('apply_filters')->returnArg(2); expect((new Embed())->pageContext())->toBe([]); }); +it('puts the current page id in chat context on a singular view', function () { + Functions\when('is_product')->justReturn(false); + Functions\when('is_singular')->justReturn(true); + Functions\when('get_the_ID')->justReturn(42); + Functions\when('apply_filters')->returnArg(2); + + expect((new Embed())->pageContext())->toBe([ + 'page_id' => '42', + ]); +}); + it('puts the current product id in chat context on a product page', function () { Functions\when('is_product')->justReturn(true); + Functions\when('is_singular')->justReturn(true); Functions\when('get_the_ID')->justReturn(1842); Functions\when('wc_get_product')->justReturn(new class { @@ -23,6 +36,7 @@ public function get_sku(): string Functions\when('apply_filters')->returnArg(2); expect((new Embed())->pageContext())->toBe([ + 'page_id' => '1842', 'product_id' => '1842', 'sku' => 'VNECK-BLU', ]); diff --git a/tests/Unit/HostNavigationTest.php b/tests/Unit/HostNavigationTest.php new file mode 100644 index 0000000..63bec2d --- /dev/null +++ b/tests/Unit/HostNavigationTest.php @@ -0,0 +1,96 @@ +justReturn('https://shop.test/'); + + return new HostNavigation(); +} + +it('registers the ajax handlers and script hook', function () { + (new HostNavigation())->register(); + + expect(Actions\has('wp_enqueue_scripts'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_datalumo_host_navigation'))->not->toBeFalse() + ->and(Actions\has('wp_ajax_nopriv_datalumo_host_navigation'))->not->toBeFalse(); +}); + +it('resolves the WooCommerce cart and checkout urls', function () { + Functions\when('wc_get_cart_url')->justReturn('https://shop.test/cart/'); + Functions\when('wc_get_checkout_url')->justReturn('https://shop.test/checkout/'); + + $nav = hostNav(); + + expect($nav->resolveUrl('view_cart', []))->toBe('https://shop.test/cart/') + ->and($nav->resolveUrl('open_checkout', []))->toBe('https://shop.test/checkout/'); +}); + +it('rejects an off-site cart url', function () { + Functions\when('wc_get_cart_url')->justReturn('https://evil.test/cart/'); + + expect(hostNav()->resolveUrl('view_cart', []))->toBeNull(); +}); + +it('opens a published page by id', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 42, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/about/'); + + expect(hostNav()->publishedUrlFromPayload(['page_id' => '42']))->toBe('https://shop.test/about/'); +}); + +it('does not open a draft page', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 42, 'post_status' => 'draft']); + Functions\when('get_permalink')->justReturn('https://shop.test/?p=42&preview=true'); + + expect(hostNav()->publishedUrlFromPayload(['page_id' => '42']))->toBeNull(); +}); + +it('opens a same-site url and rejects javascript or off-site urls', function () { + $nav = hostNav(); + + expect($nav->publishedUrlFromPayload(['url' => 'https://shop.test/contact/']))->toBe('https://shop.test/contact/') + ->and($nav->isSameSiteUrl('javascript:alert(1)'))->toBeFalse() + ->and($nav->isSameSiteUrl('https://evil.test/contact/'))->toBeFalse(); +}); + +it('opens a page by slug', function () { + Functions\when('get_page_by_path')->justReturn((object) ['ID' => 9]); + Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); + + expect(hostNav()->publishedUrlFromPayload(['slug' => 'contact']))->toBe('https://shop.test/contact/'); +}); + +it('appends a form hint when opening another page for start_form', function () { + Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); + Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); + + expect(hostNav()->resolveUrl('start_form', [ + 'page_id' => '9', + 'form_id' => 'contact', + ]))->toBe('https://shop.test/contact/?datalumo_form=contact'); +}); + +it('only allows simple selectors', function (string $selector, string $expected) { + expect(hostNav()->sanitizeSelector($selector))->toBe($expected); +})->with([ + 'id' => ['#contact', '#contact'], + 'bare id' => ['contact', 'contact'], + 'class' => ['.wpcf7-form', '.wpcf7-form'], + 'tag and class' => ['form.wpcf7-form', 'form.wpcf7-form'], + 'attribute' => ['form[action*="evil"]', ''], + 'wildcard' => ['*', ''], + 'js proto' => ['javascript:alert(1)', ''], +]); From f40e26a599f9f076cd503e2ea91963bbdaf68390 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 13:29:29 +0900 Subject: [PATCH 3/8] Send the host-action run token with late results. The embed only applies datalumo:action-result to the in-flight confirm, so the fallback event must include run. Co-authored-by: Cursor --- resources/js/add-to-cart.js | 1 + resources/js/host-navigation.js | 1 + 2 files changed, 2 insertions(+) diff --git a/resources/js/add-to-cart.js b/resources/js/add-to-cart.js index 7259383..51730fb 100644 --- a/resources/js/add-to-cart.js +++ b/resources/js/add-to-cart.js @@ -117,6 +117,7 @@ detail: { answer_id: detail.answer_id, tool_id: detail.tool_id, + run: detail.run, ok: result.ok !== false, message: result.message || '', }, diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js index 857b5c7..906c638 100644 --- a/resources/js/host-navigation.js +++ b/resources/js/host-navigation.js @@ -245,6 +245,7 @@ detail: { answer_id: detail.answer_id, tool_id: detail.tool_id, + run: detail.run, ok: result.ok !== false, message: result.message || '', }, From f88cc07295fdbe4c45f8f9e3547048a4d66b117d Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 14:00:21 +0900 Subject: [PATCH 4/8] Drop start_form and bust local assets with a timestamp. Guessing a form on the current page was not useful; Open this page covers contact flows. Local WP_ENVIRONMENT_TYPE uses time() for script cache busting instead of bumping the plugin version. Co-authored-by: Cursor --- datalumo.php | 4 +- readme.txt | 33 ++------ resources/js/host-navigation.js | 116 +---------------------------- src/Embed/Embed.php | 3 +- src/Integration/AddToCart.php | 4 +- src/Integration/HostNavigation.php | 75 +++---------------- src/Search/ClickTracking.php | 3 +- src/Search/Summary.php | 5 +- src/Support/Assets.php | 19 +++++ tests/Unit/AddToCartTest.php | 2 +- tests/Unit/AssetsTest.php | 28 +++++++ tests/Unit/HostNavigationTest.php | 24 +----- 12 files changed, 81 insertions(+), 235 deletions(-) create mode 100644 src/Support/Assets.php create mode 100644 tests/Unit/AssetsTest.php diff --git a/datalumo.php b/datalumo.php index 0a60f9f..b1f5641 100644 --- a/datalumo.php +++ b/datalumo.php @@ -7,7 +7,7 @@ * Plugin Name: Datalumo * Plugin URI: https://github.com/datalumo/wordpress * Description: Sync your WordPress content to Datalumo and add AI-powered search and chat to your site. - * Version: 0.0.15 + * Version: 0.0.8 * Requires at least: 6.0 * Requires PHP: 8.1 * Author: Datalumo @@ -25,7 +25,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.15'); + define('DATALUMO_VERSION', '0.0.8'); define('DATALUMO_FILE', __FILE__); define('DATALUMO_DIR', __DIR__); define('DATALUMO_URL', plugin_dir_url(__FILE__)); diff --git a/readme.txt b/readme.txt index da94b5b..9ab6579 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: search, ai, chatbot, rag Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.0.15 +Stable tag: 0.0.8 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,7 +19,7 @@ Datalumo keeps a searchable, AI-ready copy of your WordPress content and gives y * **Search box** — drop-in search via the `[datalumo_search]` shortcode. * **Enhanced search** — serve WordPress' native search results from Datalumo's ranking, with an optional streamed AI summary above the results. Falls back to native search automatically. * **Visitor identity** — logged-in users can continue their chat conversations across visits, verified with a server-side signature. -* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`, `start_form`). Add those actions in Datalumo under Reply → What it can do. +* **Chat page actions**: the official plugin listens for confirmed chat events (`add_to_cart`, `view_cart`, `open_checkout`, `open_page`). Add those actions in Datalumo under Reply → What it can do. This plugin is an interface to a [Datalumo](https://datalumo.app) instance. You need a Datalumo account (or a self-hosted instance), an API token, and — for chat or search widgets — a widget key. Nothing is sent until an administrator connects the site and turns a feature on. @@ -74,37 +74,14 @@ Yes, if WooCommerce is active. In Datalumo, add the WordPress Add to cart action = What other chat actions does the plugin handle? = -View cart and Open checkout (WooCommerce), Open this page (a post or page by id, slug, or same-site URL), and Start a form (scrolls to a form on this page, or opens the page that has it). Add them from Reply → Plugin actions → WordPress. +View cart and Open checkout (WooCommerce), and Open this page (a post or page by id, slug, or same-site URL). Add them from Reply → Plugin actions → WordPress. == Changelog == -= 0.0.15 = -* Chat: view_cart and open_checkout open the WooCommerce cart or checkout. -* Chat: open_page opens a published post or page (id, slug, or same-site URL). -* Chat: start_form scrolls to a form on this page, or opens the page that has it. -* Product and other singular pages send page_id as chat context. - -= 0.0.14 = -* WooCommerce: colour and size are nested choice steps in one reply, so chat does not hop back to the server after the first pick. - -= 0.0.13 = -* WooCommerce: variable products ask for colour, then size, then add the matching variation. - -= 0.0.12 = -* WooCommerce: add-to-cart sends the variation's size and colour attributes, not only the variation id. - -= 0.0.11 = -* Chat on a product page now sends that product id as page context, and add-to-cart uses the current product when the payload has no id. - -= 0.0.10 = -* WooCommerce: a variable product asks the visitor to pick a variation in chat (up to 8). More than that links to the product page. - -= 0.0.9 = -* WooCommerce: settle an add-to-cart chip through respond() only, so the success line is not stamped on an older message. - = 0.0.8 = -* WooCommerce: a confirmed chat action named add_to_cart adds the product to the visitor's cart. +* Chat page actions: add_to_cart (WooCommerce, including variable products with nested colour/size choices), view_cart, open_checkout, and open_page. +* Product and other singular pages send page_id (and product_id on products) as chat context. = 0.0.7 = * Full sync: when the push completes, the plugin now tells Datalumo to start indexing right away instead of waiting for its next background sweep. Best-effort — older Datalumo versions without the endpoint are unaffected. diff --git a/resources/js/host-navigation.js b/resources/js/host-navigation.js index 906c638..5fac3ec 100644 --- a/resources/js/host-navigation.js +++ b/resources/js/host-navigation.js @@ -1,6 +1,6 @@ /** - * Handles Datalumo host actions that open a page: cart, checkout, a - * WordPress post, or a form on this page (or another page). + * Handles Datalumo host actions that open a page: cart, checkout, or a + * WordPress post. */ (function () { 'use strict'; @@ -11,7 +11,7 @@ return; } - var events = config.events || ['view_cart', 'open_checkout', 'open_page', 'start_form']; + var events = config.events || ['view_cart', 'open_checkout', 'open_page']; window.addEventListener('datalumo:action', function (event) { var detail = event.detail || {}; @@ -36,22 +36,9 @@ return; } - if (detail.name === 'start_form') { - var form = findForm(payload); - - if (form) { - scrollToForm(form); - finish(detail, { ok: true, message: '' }); - - return; - } - } - resolveThenGo(detail, payload); }); - bootPendingForm(); - function resolveThenGo(detail, payload) { var body = new FormData(); var request = {}; @@ -113,103 +100,6 @@ window.location.assign(url); } - function findForm(payload) { - var selector = sanitizeSelector(payload.selector); - var el; - - if (selector) { - el = document.querySelector(selector); - - if (el) { - return el; - } - } - - var id = String(payload.form_id || '').replace(/[^\w-]/g, ''); - - if (id) { - el = firstMatch([ - '#' + id, - '#wpcf7-f' + id, - '#wpforms-' + id, - '#gform_' + id, - '#gform_wrapper_' + id, - ]); - - if (el) { - return el; - } - } - - return document.querySelector( - 'form.wpcf7-form, form.wpforms-form, .gform_wrapper form, form:not([role="search"]):not(.search-form):not(.woocommerce-cart-form):not(.woocommerce-checkout)', - ); - } - - function firstMatch(selectors) { - var i; - var el; - - for (i = 0; i < selectors.length; i++) { - el = document.querySelector(selectors[i]); - - if (el) { - return el; - } - } - - return null; - } - - function sanitizeSelector(value) { - var selector = String(value || '').trim(); - - if (selector === '') { - return ''; - } - - if (/^#?[A-Za-z][\w-]*$/.test(selector)) { - return selector.charAt(0) === '#' ? selector : '#' + selector; - } - - if (/^\.[A-Za-z][\w-]*$/.test(selector)) { - return selector; - } - - if (/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/.test(selector)) { - return selector; - } - - return ''; - } - - function scrollToForm(el) { - if (typeof el.scrollIntoView === 'function') { - el.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - - var focusable = el.matches && el.matches('input, textarea, select') - ? el - : el.querySelector('input:not([type="hidden"]), textarea, select'); - - if (focusable && typeof focusable.focus === 'function') { - focusable.focus({ preventScroll: true }); - } - } - - function bootPendingForm() { - var params = new URLSearchParams(window.location.search); - var pending = params.get('datalumo_form') || ''; - var hash = (window.location.hash || '').replace(/^#/, ''); - var form = findForm({ - form_id: pending || (hash && hash !== 'datalumo-form' ? hash : ''), - }); - - if (form && (pending || hash === 'datalumo-form' || hash)) { - scrollToForm(form); - } - } - function isSameSite(url) { if (! url || typeof url !== 'string') { return false; diff --git a/src/Embed/Embed.php b/src/Embed/Embed.php index 8dc20db..08a2976 100644 --- a/src/Embed/Embed.php +++ b/src/Embed/Embed.php @@ -2,6 +2,7 @@ namespace Datalumo\Wp\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -33,7 +34,7 @@ public function registerScript(): void self::SCRIPT_HANDLE, Options::baseUrl() . '/widget/v1/datalumo.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); } diff --git a/src/Integration/AddToCart.php b/src/Integration/AddToCart.php index 3e12f14..12471a7 100644 --- a/src/Integration/AddToCart.php +++ b/src/Integration/AddToCart.php @@ -2,6 +2,8 @@ namespace Datalumo\Wp\Integration; +use Datalumo\Wp\Support\Assets; + /** * Listens for a Datalumo host action named add_to_cart and adds the * product to the visitor's WooCommerce cart (their browser session). @@ -29,7 +31,7 @@ public function enqueue(): void 'datalumo-add-to-cart', DATALUMO_URL . 'resources/js/add-to-cart.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Integration/HostNavigation.php b/src/Integration/HostNavigation.php index 846815c..8f918b8 100644 --- a/src/Integration/HostNavigation.php +++ b/src/Integration/HostNavigation.php @@ -2,9 +2,11 @@ namespace Datalumo\Wp\Integration; +use Datalumo\Wp\Support\Assets; + /** * Listens for Datalumo host actions that open a page: the cart, checkout, - * a WordPress post, or a form on the current (or another) page. + * or a WordPress post. */ class HostNavigation { @@ -12,7 +14,7 @@ class HostNavigation public const NONCE = 'datalumo_host_navigation'; - public const EVENTS = ['view_cart', 'open_checkout', 'open_page', 'start_form']; + public const EVENTS = ['view_cart', 'open_checkout', 'open_page']; public function register(): void { @@ -27,7 +29,7 @@ public function enqueue(): void 'datalumo-host-navigation', DATALUMO_URL . 'resources/js/host-navigation.js', [], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); @@ -41,7 +43,6 @@ public function enqueue(): void 'i18n' => [ 'failed' => __('Could not open that page.', 'datalumo'), 'no_page' => __('No page was specified.', 'datalumo'), - 'no_form' => __('No form was found on this page.', 'datalumo'), 'no_cart' => __('The cart is not available.', 'datalumo'), 'no_checkout' => __('Checkout is not available.', 'datalumo'), ], @@ -55,18 +56,14 @@ public function handle(): void $payload = $this->requestPayload(); $event = sanitize_key((string) ($payload['event'] ?? '')); - if (! in_array($event, ['open_page', 'start_form'], true)) { + if ($event !== 'open_page') { wp_send_json_error(['message' => __('Unknown action.', 'datalumo')], 400); } $url = $this->resolveUrl($event, $payload); if ($url === null) { - wp_send_json_error([ - 'message' => $event === 'start_form' - ? __('No form was found on this page.', 'datalumo') - : __('No page was specified.', 'datalumo'), - ], 422); + wp_send_json_error(['message' => __('No page was specified.', 'datalumo')], 422); } wp_send_json_success(['url' => $url]); @@ -85,17 +82,11 @@ public function resolveUrl(string $event, array $payload): ?string return $this->checkoutUrl() ?: null; } - $url = $this->publishedUrlFromPayload($payload); - - if ($url === null) { - return null; + if ($event === 'open_page') { + return $this->publishedUrlFromPayload($payload); } - if ($event === 'start_form') { - return $this->withFormHint($url, $payload); - } - - return $url; + return null; } /** @@ -156,29 +147,6 @@ public function isSameSiteUrl(string $url): bool return $host === $home || $host === 'www.' . $home || 'www.' . $host === $home; } - public function sanitizeSelector(string $selector): string - { - $selector = trim($selector); - - if ($selector === '') { - return ''; - } - - if (preg_match('/^#?[A-Za-z][\w-]*$/', $selector) === 1) { - return $selector; - } - - if (preg_match('/^\.[A-Za-z][\w-]*$/', $selector) === 1) { - return $selector; - } - - if (preg_match('/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)?(#[A-Za-z][\w-]*)?$/', $selector) === 1) { - return $selector; - } - - return ''; - } - public function cartUrl(): string { if (! function_exists('wc_get_cart_url')) { @@ -237,29 +205,6 @@ private function positiveId(mixed $value): ?int return null; } - /** - * @param array $payload - */ - private function withFormHint(string $url, array $payload): string - { - $formId = preg_replace('/[^\w-]/', '', (string) ($payload['form_id'] ?? '')) ?? ''; - - if ($formId === '') { - return $this->appendHash($url, 'datalumo-form'); - } - - $separator = str_contains($url, '?') ? '&' : '?'; - - return $url . $separator . 'datalumo_form=' . rawurlencode($formId); - } - - private function appendHash(string $url, string $hash): string - { - $base = preg_replace('/#.*$/', '', $url) ?? $url; - - return $base . '#' . $hash; - } - /** * @return array */ diff --git a/src/Search/ClickTracking.php b/src/Search/ClickTracking.php index a7949da..a8129eb 100644 --- a/src/Search/ClickTracking.php +++ b/src/Search/ClickTracking.php @@ -3,6 +3,7 @@ namespace Datalumo\Wp\Search; use Datalumo\Wp\Embed\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -51,7 +52,7 @@ public function enqueue(): void 'datalumo-clicks', DATALUMO_URL . 'resources/js/click-tracking.js', [Embed::SCRIPT_HANDLE], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Search/Summary.php b/src/Search/Summary.php index dc61f70..7aac9a3 100644 --- a/src/Search/Summary.php +++ b/src/Search/Summary.php @@ -3,6 +3,7 @@ namespace Datalumo\Wp\Search; use Datalumo\Wp\Embed\Embed; +use Datalumo\Wp\Support\Assets; use Datalumo\Wp\Support\Options; /** @@ -50,14 +51,14 @@ public function enqueue(): void 'datalumo-summary', DATALUMO_URL . 'resources/css/summary.css', [], - DATALUMO_VERSION, + Assets::version(), ); wp_enqueue_script( 'datalumo-summary', DATALUMO_URL . 'resources/js/summary.js', [Embed::SCRIPT_HANDLE], - DATALUMO_VERSION, + Assets::version(), ['in_footer' => true], ); diff --git a/src/Support/Assets.php b/src/Support/Assets.php new file mode 100644 index 0000000..abb00fe --- /dev/null +++ b/src/Support/Assets.php @@ -0,0 +1,19 @@ +toMatch('/^\d+$/') + ->and((int) Assets::version())->toBeGreaterThanOrEqual(time() - 1); + } finally { + putenv('WP_ENVIRONMENT_TYPE'); + } +}); + +it('uses the plugin version outside local', function () { + putenv('WP_ENVIRONMENT_TYPE=production'); + + try { + expect(Assets::version())->toBe(DATALUMO_VERSION); + } finally { + putenv('WP_ENVIRONMENT_TYPE'); + } +}); diff --git a/tests/Unit/HostNavigationTest.php b/tests/Unit/HostNavigationTest.php index 63bec2d..2daedb0 100644 --- a/tests/Unit/HostNavigationTest.php +++ b/tests/Unit/HostNavigationTest.php @@ -9,7 +9,7 @@ } if (! defined('DATALUMO_VERSION')) { - define('DATALUMO_VERSION', '0.0.15'); + define('DATALUMO_VERSION', '0.0.8'); } function hostNav(): HostNavigation @@ -73,24 +73,6 @@ function hostNav(): HostNavigation expect(hostNav()->publishedUrlFromPayload(['slug' => 'contact']))->toBe('https://shop.test/contact/'); }); -it('appends a form hint when opening another page for start_form', function () { - Functions\when('get_post')->justReturn((object) ['ID' => 9, 'post_status' => 'publish']); - Functions\when('get_permalink')->justReturn('https://shop.test/contact/'); - - expect(hostNav()->resolveUrl('start_form', [ - 'page_id' => '9', - 'form_id' => 'contact', - ]))->toBe('https://shop.test/contact/?datalumo_form=contact'); +it('ignores start_form', function () { + expect(hostNav()->resolveUrl('start_form', ['page_id' => '9']))->toBeNull(); }); - -it('only allows simple selectors', function (string $selector, string $expected) { - expect(hostNav()->sanitizeSelector($selector))->toBe($expected); -})->with([ - 'id' => ['#contact', '#contact'], - 'bare id' => ['contact', 'contact'], - 'class' => ['.wpcf7-form', '.wpcf7-form'], - 'tag and class' => ['form.wpcf7-form', 'form.wpcf7-form'], - 'attribute' => ['form[action*="evil"]', ''], - 'wildcard' => ['*', ''], - 'js proto' => ['javascript:alert(1)', ''], -]); From 14ead71098f4fb48671eb74640bfe4dce7171ab2 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Fri, 28 Aug 2026 14:07:06 +0900 Subject: [PATCH 5/8] Prepare the plugin for a WordPress.org listing. Drop the GitHub update checker, add a license and dist ignore list, and tighten settings, notices, and logging for Plugin Check. Co-authored-by: Cursor --- .distignore | 12 +++++ .github/workflows/release.yml | 9 ++-- .gitignore | 1 + composer.json | 3 +- license.txt | 18 +++++++ resources/js/setup-notice.js | 28 ++++++++++ resources/views/settings.php | 97 +++++++++++++++++++---------------- src/Admin/Ajax.php | 21 ++++++-- src/Admin/SettingsPage.php | 32 +++++++++--- src/Api/Client.php | 16 +++--- src/Plugin.php | 47 +++++++++++++---- src/Search/Interceptor.php | 12 ++--- src/Support/UpdateChecker.php | 34 ------------ src/Sync/BulkSync.php | 5 +- src/Sync/ContentSync.php | 2 - uninstall.php | 5 +- 16 files changed, 218 insertions(+), 124 deletions(-) create mode 100644 .distignore create mode 100644 license.txt create mode 100644 resources/js/setup-notice.js delete mode 100644 src/Support/UpdateChecker.php diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..9bb3c44 --- /dev/null +++ b/.distignore @@ -0,0 +1,12 @@ +.git +.github +.gitignore +.distignore +.slimm +.phpunit.cache +tests +phpunit.xml +composer.lock +build +*.zip +.DS_Store diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a749ee7..76a7a35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,8 @@ name: Release # Build a distributable plugin zip and attach it to the GitHub release whenever -# a version tag is pushed. The Datalumo plugin checks for updates against these -# release assets (see src/Support/UpdateChecker.php), so the zip must contain the -# Composer-built vendor/ directory — GitHub's auto-generated source zip does not. +# a version tag is pushed. The zip must contain the Composer-built vendor/ +# directory — GitHub's auto-generated source archive does not. on: push: tags: @@ -52,12 +51,14 @@ jobs: --exclude '.git' \ --exclude '.github' \ --exclude '.gitignore' \ + --exclude '.distignore' \ --exclude '.slimm' \ --exclude 'build' \ --exclude '*.zip' \ --exclude 'tests' \ --exclude 'phpunit.xml' \ - --exclude '.phpunit.cache' + --exclude '.phpunit.cache' \ + --exclude '.DS_Store' (cd build && zip -rq datalumo.zip datalumo) - name: Create release and upload asset diff --git a/.gitignore b/.gitignore index 567402e..ac84e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ *.zip .slimm/ .phpunit.cache/ +.DS_Store diff --git a/composer.json b/composer.json index 74eb1b0..0a4f68b 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,7 @@ "require": { "php": ">=8.1", "automattic/jetpack-autoloader": "^3.0", - "woocommerce/action-scheduler": "^3.0", - "yahnis-elsts/plugin-update-checker": "^5.7" + "woocommerce/action-scheduler": "^3.0" }, "autoload": { "psr-4": { diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..5434d58 --- /dev/null +++ b/license.txt @@ -0,0 +1,18 @@ +Datalumo WordPress Plugin +Copyright (C) 2026 Jeffrey van Rossum + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, see . + +License: GPL-2.0-or-later +License URI: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/resources/js/setup-notice.js b/resources/js/setup-notice.js new file mode 100644 index 0000000..48434b6 --- /dev/null +++ b/resources/js/setup-notice.js @@ -0,0 +1,28 @@ +/** + * Persist dismissal of the dashboard setup notice. + */ +(function () { + 'use strict'; + + var config = window.datalumoSetupNotice; + + if (! config) { + return; + } + + document.addEventListener('click', function (event) { + var dismiss = event.target && event.target.closest + ? event.target.closest('.datalumo-setup-notice .notice-dismiss') + : null; + + if (! dismiss) { + return; + } + + var body = new FormData(); + body.append('action', 'datalumo_dismiss_setup_notice'); + body.append('_ajax_nonce', config.nonce); + + fetch(config.ajaxUrl, { method: 'POST', body: body, credentials: 'same-origin' }); + }); +})(); diff --git a/resources/views/settings.php b/resources/views/settings.php index 8cc98c1..0cac4f9 100644 --- a/resources/views/settings.php +++ b/resources/views/settings.php @@ -1,47 +1,52 @@ true], 'objects'); -unset($postTypes['attachment']); +/** @var string $datalumo_tab (set by SettingsPage::render) */ +$datalumo_organisation = Options::get('organisation', []); +$datalumo_sources = (array) Options::get('sources', []); +$datalumo_syncs = (array) Options::get('syncs', []); +$datalumo_post_types = get_post_types(['public' => true], 'objects'); +unset($datalumo_post_types['attachment']); -$tabs = [ +$datalumo_tabs = [ 'connection' => __('Connection', 'datalumo'), 'content-sync' => __('Content sync', 'datalumo'), 'chatbot' => __('Chatbot', 'datalumo'), 'search-box' => __('Search box', 'datalumo'), 'enhanced-search' => __('Enhanced search', 'datalumo'), ]; + +$datalumo_updated = isset($_GET['updated'], $_GET['_wpnonce']) + && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'datalumo_settings_updated') + && sanitize_text_field(wp_unslash($_GET['updated'])) === '1'; ?>

Datalumo

- +

- + - + @@ -57,7 +62,7 @@ class="nav-tab "> @@ -81,11 +86,14 @@ class="nav-tab "> - + esc_html($datalumo_organisation['name'] ?? $datalumo_organisation['id'] ?? ''), + count($datalumo_sources), + ); + ?> @@ -94,24 +102,24 @@ class="nav-tab "> - +

- $sync) : ?> -
- + $datalumo_sync) : ?> +
+
- + - @@ -148,7 +156,7 @@ class="nav-tab ">

- + @@ -186,8 +194,11 @@ class="nav-tab ">

+ +

+ - + @@ -199,7 +210,7 @@ class="nav-tab "> - + @@ -221,11 +232,11 @@ class="nav-tab "> + + + +
+ +
+ + + + +

-

+ 1; + ?>
- $datalumo_sync) : ?> -
+ $datalumo_sync) : ?> +
@@ -117,12 +261,35 @@ class="nav-tab + +

+ +

+ @@ -153,7 +320,6 @@ class="nav-tab -

@@ -188,15 +354,11 @@ class="nav-tab -

- -

- @@ -268,4 +430,31 @@ class="nav-tab + + + + + + + diff --git a/src/Admin/Ajax.php b/src/Admin/Ajax.php index 6fef90d..86a0f8a 100644 --- a/src/Admin/Ajax.php +++ b/src/Admin/Ajax.php @@ -4,6 +4,7 @@ use Datalumo\Wp\Api\ApiException; use Datalumo\Wp\Api\Client; +use Datalumo\Wp\Support\Credentials; use Datalumo\Wp\Support\Options; use Datalumo\Wp\Sync\BulkSync; @@ -21,10 +22,10 @@ public function register(): void } /** - * Verify the pasted org id + token against /me; on success cache the - * organisation and its sources so the pickers render offline. + * Verify a pasted API token against GET /api/v1/me. The token identifies + * the organisation; no organisation ID is required. * - * An empty POST token falls back to the stored one — the password field + * An empty POST token falls back to the stored one. The password field * renders only a "saved" placeholder, never the secret. The base URL comes * from the form field (so an unsaved value works) and is persisted on success. */ @@ -33,35 +34,39 @@ public function connect(): void $this->authorise(); // phpcs:disable WordPress.Security.NonceVerification -- verified in authorise(). - $organisationId = sanitize_text_field(wp_unslash((string) ($_POST['organisation_id'] ?? ''))); $token = sanitize_text_field(wp_unslash((string) ($_POST['token'] ?? ''))); $apiUrl = array_key_exists('api_url', $_POST) ? (esc_url_raw(wp_unslash((string) $_POST['api_url'])) ?: 'https://datalumo.app') : null; // phpcs:enable WordPress.Security.NonceVerification - if ($organisationId === '') { - $organisationId = (string) Options::get('organisation.id', ''); + if ($token === '') { + $token = (string) Options::get('api_token', ''); } if ($token === '') { - $token = (string) Options::get('api_token', ''); + wp_send_json_error(['message' => __('An API token is required.', 'datalumo')]); + } + + if (Credentials::looksLikeWidgetKey($token)) { + wp_send_json_error(['message' => __('That looks like a widget key. Paste an API token from API keys instead.', 'datalumo')]); } - if ($organisationId === '' || $token === '') { - wp_send_json_error(['message' => __('Both the organisation ID and an API token are required.', 'datalumo')]); + if (Credentials::looksLikeSecret($token)) { + wp_send_json_error(['message' => __('That looks like a widget secret. Paste an API token from API keys instead.', 'datalumo')]); } try { - $me = (new Client())->me($organisationId, $token, $apiUrl); + $me = (new Client())->me('', $token, $apiUrl); } catch (ApiException $e) { wp_send_json_error(['message' => $e->getMessage()]); } $stored = [ 'api_token' => $token, - 'organisation' => $me['organisation'] ?? ['id' => $organisationId], + 'organisation' => $me['organisation'] ?? [], 'sources' => $me['sources'] ?? [], + 'connected_via' => 'token', ]; if ($apiUrl !== null) { diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index f53fc2e..efeab38 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -2,15 +2,22 @@ namespace Datalumo\Wp\Admin; +use Datalumo\Wp\Api\ApiException; +use Datalumo\Wp\Api\Client; use Datalumo\Wp\Support\Assets; +use Datalumo\Wp\Support\Credentials; use Datalumo\Wp\Support\Options; class SettingsPage { public const NONCE = 'datalumo_settings'; + public const ASK_MAX_LENGTH = 280; + private const TABS = ['connection', 'content-sync', 'chatbot', 'search-box', 'enhanced-search']; + private bool $widgetKeyRejected = false; + public function register(): void { add_action('admin_menu', function (): void { @@ -55,14 +62,68 @@ public function render(): void { $datalumo_tab = $this->requestedTab(); + if ($datalumo_tab === 'content-sync') { + $this->refreshSources(); + } + require DATALUMO_DIR . '/resources/views/settings.php'; } + /** + * Other tabs are empty until a connection exists and the post-connect + * checklist (if any) has been saved. + */ + public static function setupIsReady(): bool + { + return Options::isConnected() && ! Options::get('setup_pending'); + } + + public static function docsUrl(string $hash = ''): string + { + $url = Options::baseUrl().'/docs/wordpress'; + + return $hash !== '' ? $url.'#'.$hash : $url; + } + + /** + * Landing page for the settings "Need help?" form. Query `ask` is + * honoured on first-party docs only, as a composer prefill. + */ + public static function helpUrl(): string + { + return self::docsUrl(); + } + + /** + * Pull a fresh source list (including knowledge base names) so the + * picker stays current after a connect or a rename in Datalumo. + */ + private function refreshSources(): void + { + if (! Options::isConnected()) { + return; + } + + try { + $me = (new Client())->me('', (string) Options::get('api_token')); + } catch (ApiException) { + return; + } + + if (isset($me['sources']) && is_array($me['sources'])) { + Options::set('sources', $me['sources']); + } + } + private function requestedTab(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- settings tab is a navigation query arg. $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : ''; + if (! self::setupIsReady()) { + return 'connection'; + } + return in_array($tab, self::TABS, true) ? $tab : 'connection'; } @@ -85,16 +146,16 @@ public function save(): void 'content-sync' => $this->saveSyncs($input), 'chatbot' => Options::merge(['chatbot' => [ 'enabled' => ! empty($input['chatbot_enabled']), - 'widget_key' => sanitize_text_field((string) ($input['chatbot_widget_key'] ?? '')), + 'widget_key' => $this->sanitizeWidgetKey((string) ($input['chatbot_widget_key'] ?? ''), (string) Options::get('chatbot.widget_key', '')), 'identity_enabled' => ! empty($input['chatbot_identity_enabled']), - 'signing_secret' => sanitize_text_field((string) ($input['chatbot_signing_secret'] ?? '')), + 'signing_secret' => $this->sanitizeSigningSecret((string) ($input['chatbot_signing_secret'] ?? '')), ]]), 'search-box' => Options::merge(['search_box' => [ - 'widget_key' => sanitize_text_field((string) ($input['search_box_widget_key'] ?? '')), + 'widget_key' => $this->sanitizeWidgetKey((string) ($input['search_box_widget_key'] ?? ''), (string) Options::get('search_box.widget_key', '')), ]]), 'enhanced-search' => Options::merge(['enhanced' => [ 'enabled' => ! empty($input['enhanced_enabled']), - 'widget_key' => sanitize_text_field((string) ($input['enhanced_widget_key'] ?? '')), + 'widget_key' => $this->sanitizeWidgetKey((string) ($input['enhanced_widget_key'] ?? ''), (string) Options::get('enhanced.widget_key', '')), 'post_types' => array_map('sanitize_key', (array) ($input['enhanced_post_types'] ?? [])), 'summary_enabled' => ! empty($input['enhanced_summary_enabled']), 'summary_selector' => sanitize_text_field((string) ($input['enhanced_summary_selector'] ?? '')), @@ -103,15 +164,20 @@ public function save(): void default => $this->saveConnection($input), }; - wp_safe_redirect(add_query_arg( - [ - 'page' => 'datalumo', - 'tab' => $tab, - 'updated' => '1', - '_wpnonce' => wp_create_nonce('datalumo_settings_updated'), - ], - admin_url('options-general.php'), - )); + $args = [ + 'page' => 'datalumo', + 'tab' => $tab, + '_wpnonce' => wp_create_nonce('datalumo_settings_updated'), + ]; + + if ($this->widgetKeyRejected) { + $args['datalumo_notice'] = 'invalid_widget_key'; + } else { + // options-head.php prints "Settings saved." when updated=1 on a Settings screen. + $args['updated'] = '1'; + } + + wp_safe_redirect(add_query_arg($args, admin_url('options-general.php'))); exit; } @@ -164,4 +230,24 @@ private function saveSyncs(array $input): void Options::set('syncs', $syncs); } + + private function sanitizeWidgetKey(string $value, string $current = ''): string + { + $value = sanitize_text_field($value); + + if ($value === '' || Credentials::looksLikeWidgetKey($value)) { + return $value; + } + + $this->widgetKeyRejected = true; + + return $current; + } + + private function sanitizeSigningSecret(string $value): string + { + $value = sanitize_text_field($value); + + return $value !== '' ? $value : (string) Options::get('chatbot.signing_secret', ''); + } } diff --git a/src/Admin/index.php b/src/Admin/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Admin/index.php @@ -0,0 +1,2 @@ +request('GET', $this->rootUrl('api/v1/me', $baseUrl), token: $token); + } + return $this->request('GET', $this->orgUrl($organisationId, 'me', $baseUrl), token: $token); } + /** + * @return array + */ + public function exchangeWordPressGrant(string $code, string $state, ?string $baseUrl = null): array + { + return $this->request( + 'POST', + $this->rootUrl('api/integrations/wordpress/exchange', $baseUrl), + ['code' => $code, 'state' => $state], + anonymous: true, + ); + } + public function pushPage(string $sourceId, array $payload): array { return $this->request('POST', $this->sourceUrl($sourceId, 'pages'), $payload); @@ -70,13 +88,18 @@ public function search(string $widgetKey, string $query, int $limit = 50, ?strin ); } - private function orgUrl(string $organisationId, string $path, ?string $baseUrl = null): string + private function rootUrl(string $path, ?string $baseUrl = null): string { $base = $baseUrl !== null && $baseUrl !== '' ? Options::normaliseBaseUrl($baseUrl) : Options::baseUrl(); - return $base . '/api/v1/' . rawurlencode($organisationId) . '/' . $path; + return $base . '/' . ltrim($path, '/'); + } + + private function orgUrl(string $organisationId, string $path, ?string $baseUrl = null): string + { + return $this->rootUrl('api/v1/' . rawurlencode($organisationId) . '/' . $path, $baseUrl); } private function sourceUrl(string $sourceId, string $path): string @@ -100,7 +123,7 @@ public function parseWidgetKey(string $key): array return [$parts[0], $parts[1]]; } - private function request(string $method, string $url, array $body = [], ?string $token = null, ?string $origin = null): array + private function request(string $method, string $url, array $body = [], ?string $token = null, ?string $origin = null, bool $anonymous = false): array { $headers = [ 'Accept' => 'application/json', @@ -109,7 +132,7 @@ private function request(string $method, string $url, array $body = [], ?string if ($origin !== null) { $headers['Origin'] = $origin; - } else { + } elseif (! $anonymous) { $token ??= (string) Options::get('api_token'); if ($token !== '') { diff --git a/src/Api/index.php b/src/Api/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Api/index.php @@ -0,0 +1,2 @@ +exchangeWordPressGrant($code, $state); + } catch (ApiException $e) { + self::redirectSettings('connection', 'grant_failed'); + } + + self::apply($payload); + self::redirectSettings('connection', 'connected'); + } + + public static function disconnect(): void + { + self::authorise(); + check_admin_referer(self::ACTION_DISCONNECT); + + $chatbot = (array) Options::get('chatbot', []); + $chatbot['widget_key'] = ''; + $chatbot['signing_secret'] = ''; + $chatbot['enabled'] = false; + $chatbot['identity_enabled'] = false; + + $searchBox = (array) Options::get('search_box', []); + $searchBox['widget_key'] = ''; + + $enhanced = (array) Options::get('enhanced', []); + $enhanced['widget_key'] = ''; + $enhanced['enabled'] = false; + + Options::merge([ + 'api_token' => '', + 'organisation' => [], + 'sources' => [], + 'setup_pending' => false, + 'setup_source_id' => '', + 'connected_via' => '', + 'chatbot' => $chatbot, + 'search_box' => $searchBox, + 'enhanced' => $enhanced, + ]); + + self::redirectSettings('connection', 'disconnected'); + } + + public static function saveSetup(): void + { + self::authorise(); + check_admin_referer(self::ACTION_SETUP); + + $input = wp_unslash($_POST); + $sourceId = (string) Options::get('setup_source_id', ''); + $postTypes = array_map('sanitize_key', (array) ($input['setup_post_types'] ?? [])); + + if ($sourceId !== '' && $postTypes !== []) { + $syncs = (array) Options::get('syncs', []); + $existing = false; + + foreach ($syncs as $index => $row) { + if (($row['source_id'] ?? '') === $sourceId) { + $syncs[$index]['post_types'] = $postTypes; + $existing = true; + break; + } + } + + if (! $existing) { + $syncs[] = [ + 'id' => wp_generate_uuid4(), + 'source_id' => $sourceId, + 'post_types' => $postTypes, + 'meta_mappings' => [], + ]; + } + + Options::set('syncs', $syncs); + } + + $chatbot = (array) Options::get('chatbot', []); + $chatbot['enabled'] = ! empty($input['setup_chat_enabled']); + $chatbot['identity_enabled'] = ! empty($input['setup_identity_enabled']); + Options::set('chatbot', $chatbot); + + $enhanced = (array) Options::get('enhanced', []); + $enhanced['enabled'] = ! empty($input['setup_enhanced_enabled']); + Options::set('enhanced', $enhanced); + + Options::set('setup_pending', false); + + $args = [ + 'page' => 'datalumo', + 'tab' => 'connection', + 'datalumo_notice' => 'setup_saved', + '_wpnonce' => wp_create_nonce('datalumo_settings_updated'), + ]; + + if (! empty($input['setup_sync_now']) && $sourceId !== '') { + $syncId = ''; + + foreach ((array) Options::get('syncs', []) as $row) { + if (($row['source_id'] ?? '') === $sourceId) { + $syncId = (string) ($row['id'] ?? ''); + break; + } + } + + if ($syncId !== '') { + $args['tab'] = 'content-sync'; + $args['datalumo_sync'] = $syncId; + } + } + + wp_safe_redirect(add_query_arg($args, admin_url('options-general.php'))); + exit; + } + + /** + * @param array $payload + */ + public static function apply(array $payload): void + { + $organisation = is_array($payload['organisation'] ?? null) ? $payload['organisation'] : []; + $sources = is_array($payload['sources'] ?? null) ? $payload['sources'] : []; + $source = is_array($payload['source'] ?? null) ? $payload['source'] : []; + $chatbot = is_array($payload['chatbot'] ?? null) ? $payload['chatbot'] : null; + $search = is_array($payload['search'] ?? null) ? $payload['search'] : null; + + $sourceId = (string) ($source['id'] ?? ''); + $stored = [ + 'api_token' => (string) ($payload['token'] ?? ''), + 'organisation' => $organisation, + 'sources' => $sources, + 'setup_pending' => true, + 'setup_source_id' => $sourceId, + 'connected_via' => 'grant', + ]; + + if ($sourceId !== '') { + $syncs = (array) Options::get('syncs', []); + $hasSource = false; + + foreach ($syncs as $row) { + if (($row['source_id'] ?? '') === $sourceId) { + $hasSource = true; + break; + } + } + + if (! $hasSource) { + $syncs[] = [ + 'id' => wp_generate_uuid4(), + 'source_id' => $sourceId, + 'post_types' => ['post', 'page'], + 'meta_mappings' => [], + ]; + $stored['syncs'] = $syncs; + } + } + + if (is_array($chatbot)) { + $stored['chatbot'] = array_merge((array) Options::get('chatbot', []), [ + 'widget_key' => (string) ($chatbot['widget_key'] ?? ''), + 'signing_secret' => (string) ($chatbot['signing_secret'] ?? ''), + ]); + } + + if (is_array($search)) { + $key = (string) ($search['widget_key'] ?? ''); + $stored['search_box'] = array_merge((array) Options::get('search_box', []), [ + 'widget_key' => $key, + ]); + $stored['enhanced'] = array_merge((array) Options::get('enhanced', []), [ + 'widget_key' => $key, + ]); + } + + Options::merge($stored); + } + + public static function startUrl(string $baseUrl, string $state): string + { + $base = Options::normaliseBaseUrl($baseUrl); + $home = home_url('/'); + $host = (string) wp_parse_url($home, PHP_URL_HOST); + + return $base.'/integrations/wordpress?'.http_build_query([ + 'site' => $host, + 'site_url' => $home, + 'return' => admin_url('admin-post.php?action='.self::ACTION_CALLBACK), + 'cancel' => admin_url('options-general.php').'?page=datalumo&tab=connection', + 'state' => $state, + ]); + } + + private static function authorise(): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('Not allowed.', 'datalumo')); + } + } + + private static function redirectSettings(string $tab, string $notice): void + { + wp_safe_redirect(add_query_arg( + [ + 'page' => 'datalumo', + 'tab' => $tab, + 'datalumo_notice' => $notice, + '_wpnonce' => wp_create_nonce('datalumo_settings_updated'), + ], + admin_url('options-general.php'), + )); + exit; + } +} diff --git a/src/Connect/index.php b/src/Connect/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Connect/index.php @@ -0,0 +1,2 @@ +register(); } + /** + * Default product fields for search and chat: short description, + * product categories/tags, visible attributes, and SKU (including + * variation SKUs). A mapping that already set sku is left alone. + * + * @param array $payload + * @return array + */ + public function enrichProduct(array $payload, WP_Post $post): array + { + if (! in_array($post->post_type, ['product', 'product_variation'], true) + || ! function_exists('wc_get_product')) { + return $payload; + } + + $product = wc_get_product($post->ID); + + if (! $product) { + return $payload; + } + + $payload = $this->attachShortDescription($payload, $product); + $payload = $this->attachTaxonomies($payload, $post); + $payload = $this->attachAttributes($payload, $product); + + return $this->attachSku($payload, $product); + } + + /** + * @param array $payload + * @return array + */ + private function attachShortDescription(array $payload, object $product): array + { + if (! method_exists($product, 'get_short_description')) { + return $payload; + } + + $short = trim(wp_strip_all_tags((string) $product->get_short_description())); + + if ($short === '') { + return $payload; + } + + $html = '

'.esc_html($short).'

'; + $content = trim((string) ($payload['content'] ?? '')); + + if ($content === '') { + $payload['content'] = $html; + } elseif (! str_contains($content, $short)) { + $payload['content'] = $html."\n\n".$content; + } + + return $payload; + } + + /** + * @param array $payload + * @return array + */ + private function attachTaxonomies(array $payload, WP_Post $post): array + { + $categoryNames = $this->termField($post->ID, 'product_cat', 'name'); + $categorySlugs = $this->termField($post->ID, 'product_cat', 'slug'); + $tagNames = $this->termField($post->ID, 'product_tag', 'name'); + $tagSlugs = $this->termField($post->ID, 'product_tag', 'slug'); + $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : []; + $lines = []; + + if ($categorySlugs !== []) { + $meta['categories'] = $categorySlugs; + $lines[] = 'category: '.implode(', ', $categoryNames !== [] ? $categoryNames : $categorySlugs); + } + + if ($tagSlugs !== []) { + $meta['tags'] = $tagSlugs; + $lines[] = 'tag: '.implode(', ', $tagNames !== [] ? $tagNames : $tagSlugs); + } + + $payload['meta'] = $meta; + + return $this->appendSearchableLines($payload, $lines); + } + + /** + * @param array $payload + * @return array + */ + private function attachAttributes(array $payload, object $product): array + { + if (! method_exists($product, 'get_attributes')) { + return $payload; + } + + $attributes = []; + $lines = []; + + foreach ($product->get_attributes() as $attribute) { + if (! is_object($attribute)) { + continue; + } + + if (method_exists($attribute, 'get_visible') && ! $attribute->get_visible()) { + continue; + } + + $label = $this->attributeLabel($attribute); + $values = $this->attributeValues($product, $attribute); + + if ($label === '' || $values === []) { + continue; + } + + $attributes[$label] = $values; + $lines[] = $label.': '.implode(', ', $values); + } + + if ($attributes === []) { + return $payload; + } + + $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : []; + + if (! isset($meta['attributes'])) { + $meta['attributes'] = $attributes; + $payload['meta'] = $meta; + } + + return $this->appendSearchableLines($payload, $lines); + } + + /** + * @param array $payload + * @return array + */ + private function attachSku(array $payload, object $product): array + { + $skus = $this->productSkus($product); + + if ($skus === []) { + return $payload; + } + + $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : []; + + if (isset($meta['sku']) && $meta['sku'] !== '' && $meta['sku'] !== []) { + return $payload; + } + + $meta['sku'] = count($skus) === 1 ? $skus[0] : $skus; + $payload['meta'] = $meta; + + return $this->appendSearchableLines($payload, ['sku: '.implode(', ', $skus)]); + } + + /** + * @return array + */ + private function productSkus(object $product): array + { + $skus = []; + $own = trim((string) $product->get_sku()); + + if ($own !== '') { + $skus[] = $own; + } + + if (method_exists($product, 'is_type') && $product->is_type('variable') && method_exists($product, 'get_children')) { + foreach ($product->get_children() as $childId) { + $child = wc_get_product((int) $childId); + + if (! $child) { + continue; + } + + $sku = trim((string) $child->get_sku()); + + if ($sku !== '') { + $skus[] = $sku; + } + } + } + + return array_values(array_unique($skus)); + } + + /** + * @return array + */ + private function termField(int $postId, string $taxonomy, string $field): array + { + $terms = get_the_terms($postId, $taxonomy); + + if (! is_array($terms)) { + return []; + } + + $values = []; + + foreach ($terms as $term) { + $value = trim((string) (is_object($term) ? ($term->{$field} ?? '') : '')); + + if ($value !== '') { + $values[] = $value; + } + } + + return array_values(array_unique($values)); + } + + private function attributeLabel(object $attribute): string + { + $name = method_exists($attribute, 'get_name') ? (string) $attribute->get_name() : ''; + + if (function_exists('wc_attribute_label') && $name !== '') { + $label = trim((string) wc_attribute_label($name)); + + if ($label !== '') { + return $label; + } + } + + return $name; + } + + /** + * @return array + */ + private function attributeValues(object $product, object $attribute): array + { + if (method_exists($attribute, 'is_taxonomy') && $attribute->is_taxonomy() + && method_exists($attribute, 'get_name') + && function_exists('wc_get_product_terms')) { + $id = method_exists($product, 'get_id') ? (int) $product->get_id() : 0; + $terms = wc_get_product_terms($id, $attribute->get_name(), ['fields' => 'names']); + + return is_array($terms) + ? array_values(array_filter(array_map(strval(...), $terms))) + : []; + } + + $options = method_exists($attribute, 'get_options') ? $attribute->get_options() : []; + + return array_values(array_filter(array_map(strval(...), (array) $options))); + } + + /** + * @param array $payload + * @param array $lines + * @return array + */ + private function appendSearchableLines(array $payload, array $lines): array + { + if ($lines === []) { + return $payload; + } + + $payload['content'] = rtrim((string) ($payload['content'] ?? '')) + ."\n\n

".esc_html(implode("\n", $lines)).'

'; + + return $payload; + } + /** * WooCommerce catalog orderings, resolved against product meta. * diff --git a/src/Integration/index.php b/src/Integration/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Integration/index.php @@ -0,0 +1,2 @@ +register(); (new Ajax())->register(); + Grant::register(); } (new ContentSync())->register(); diff --git a/src/Search/index.php b/src/Search/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Search/index.php @@ -0,0 +1,2 @@ +cleanContent($post); - if ($content === '') { - return null; - } - $meta = [ 'post_type' => $post->post_type, 'categories' => $this->termSlugs($post->ID, 'category'), @@ -70,7 +66,18 @@ public function prepare(WP_Post $post, array $metaMappings = []): ?array 'meta' => array_filter($meta, fn ($value) => $value !== null && $value !== '' && $value !== []), ]; - return apply_filters('datalumo_page_payload', $payload, $post); + $payload = apply_filters('datalumo_page_payload', $payload, $post); + + if (! is_array($payload)) { + return null; + } + + // WooCommerce may fill an empty body (short description, SKU, attributes). + if (trim(wp_strip_all_tags((string) ($payload['content'] ?? ''))) === '') { + return null; + } + + return $payload; } /** @@ -80,17 +87,45 @@ public function prepare(WP_Post $post, array $metaMappings = []): ?array */ private function cleanContent(WP_Post $post): string { + $this->ensureWooCommerceNotices(); + $raw = (string) $post->post_content; $raw = apply_filters('datalumo_render_shortcodes', false) ? do_shortcode($raw) : strip_shortcodes($raw); - $html = wp_filter_content_tags(wptexturize(do_blocks(wpautop($raw)))); + $raw = wpautop($raw); + + try { + $html = wp_filter_content_tags(wptexturize(do_blocks($raw))); + } catch (\Throwable) { + // WooCommerce Blocks (cart, checkout) call frontend helpers that + // are not loaded under WP-Cron. Keep the rest of the batch going. + $html = wp_filter_content_tags(wptexturize($raw)); + } return trim(wp_strip_all_tags($html) === '' ? '' : $html); } + /** + * WooCommerce only loads notice helpers on the storefront. Action + * Scheduler still runs do_blocks(), and Hydration::cache_store_notices() + * fatals if wc_get_notices() is missing. + */ + private function ensureWooCommerceNotices(): void + { + if (function_exists('wc_get_notices') || ! defined('WC_ABSPATH')) { + return; + } + + $file = WC_ABSPATH.'includes/wc-notice-functions.php'; + + if (is_readable($file)) { + require_once $file; + } + } + /** * @return array */ diff --git a/src/Sync/index.php b/src/Sync/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/src/Sync/index.php @@ -0,0 +1,2 @@ +justReturn(false); + Functions\when('wp_json_encode')->alias(fn ($value) => json_encode($value)); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_body')->justReturn('{"organisation":{"id":"org-1"}}'); + Functions\when('wp_remote_retrieve_header')->justReturn(''); + + $seen = null; + Functions\when('wp_remote_request')->alias(function (string $url) use (&$seen) { + $seen = $url; + + return ['response' => ['code' => 200], 'body' => '{"organisation":{"id":"org-1"}}']; + }); + + $result = (new Client())->me('', 'plain-token'); + + expect($seen)->toBe('https://datalumo.app/api/v1/me') + ->and($result['organisation']['id'])->toBe('org-1'); +}); + +it('keeps the org-scoped /me path when an organisation id is given', function () { + Functions\when('is_wp_error')->justReturn(false); + Functions\when('wp_json_encode')->alias(fn ($value) => json_encode($value)); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_body')->justReturn('{"organisation":{"id":"org-1"}}'); + Functions\when('wp_remote_retrieve_header')->justReturn(''); + + $seen = null; + Functions\when('wp_remote_request')->alias(function (string $url) use (&$seen) { + $seen = $url; + + return ['response' => ['code' => 200], 'body' => '{"organisation":{"id":"org-1"}}']; + }); + + (new Client())->me('org-1', 'plain-token'); + + expect($seen)->toBe('https://datalumo.app/api/v1/org-1/me'); +}); + +it('parses a compound widget key and rejects a secret', function () { + Functions\when('esc_html__')->returnArg(1); + + expect((new Client())->parseWidgetKey('org-1/widget-2'))->toBe(['org-1', 'widget-2']) + ->and(fn () => (new Client())->parseWidgetKey('dl_secret')) + ->toThrow(ApiException::class); +}); diff --git a/tests/Unit/CredentialsTest.php b/tests/Unit/CredentialsTest.php new file mode 100644 index 0000000..ae95fe8 --- /dev/null +++ b/tests/Unit/CredentialsTest.php @@ -0,0 +1,20 @@ +toBeTrue(); +}); + +it('does not treat a secret as a widget key', function () { + expect(Credentials::looksLikeWidgetKey('dl_abc123'))->toBeFalse(); +}); + +it('does not treat a bare token as a widget key', function () { + expect(Credentials::looksLikeWidgetKey('1|plain-api-token'))->toBeFalse(); +}); + +it('recognises a dl_ secret', function () { + expect(Credentials::looksLikeSecret('dl_abc123'))->toBeTrue() + ->and(Credentials::looksLikeSecret('1|plain-api-token'))->toBeFalse(); +}); diff --git a/tests/Unit/GrantTest.php b/tests/Unit/GrantTest.php new file mode 100644 index 0000000..83fcf33 --- /dev/null +++ b/tests/Unit/GrantTest.php @@ -0,0 +1,104 @@ +justReturn('https://shop.example/'); + Functions\when('admin_url')->alias(fn (string $path) => 'https://shop.example/wp-admin/'.$path); + Functions\when('wp_parse_url')->alias(fn (string $url, int $component = -1) => parse_url($url, $component)); + + expect(Grant::startUrl('https://datalumo.app', 'state-1'))->toBe( + 'https://datalumo.app/integrations/wordpress?'.http_build_query([ + 'site' => 'shop.example', + 'site_url' => 'https://shop.example/', + 'return' => 'https://shop.example/wp-admin/admin-post.php?action=datalumo_oauth_callback', + 'cancel' => 'https://shop.example/wp-admin/options-general.php?page=datalumo&tab=connection', + 'state' => 'state-1', + ]), + ); +}); + +it('maps an exchange payload onto stored options', function () { + Grant::apply([ + 'token' => 'tok_1', + 'organisation' => ['id' => 'org_1', 'name' => 'Acme'], + 'sources' => [['id' => 'src_1', 'name' => 'shop.example']], + 'source' => ['id' => 'src_1', 'name' => 'shop.example'], + 'chatbot' => ['widget_key' => 'org_1/chat_1', 'signing_secret' => 'dl_secret'], + 'search' => ['widget_key' => 'org_1/search_1'], + ]); + + expect(Options::get('api_token'))->toBe('tok_1') + ->and(Options::get('organisation.id'))->toBe('org_1') + ->and(Options::get('setup_pending'))->toBeTrue() + ->and(Options::get('setup_source_id'))->toBe('src_1') + ->and(Options::get('connected_via'))->toBe('grant') + ->and(Options::get('chatbot.widget_key'))->toBe('org_1/chat_1') + ->and(Options::get('chatbot.signing_secret'))->toBe('dl_secret') + ->and(Options::get('search_box.widget_key'))->toBe('org_1/search_1') + ->and(Options::get('enhanced.widget_key'))->toBe('org_1/search_1') + ->and(Options::get('syncs.0.source_id'))->toBe('src_1') + ->and(Options::get('syncs.0.post_types'))->toBe(['post', 'page']); +}); + +it('sends the browser to Datalumo with wp_redirect so an off-site host is not rewritten', function () { + Functions\when('current_user_can')->justReturn(true); + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('set_transient')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('esc_url_raw')->returnArg(1); + Functions\when('wp_unslash')->returnArg(1); + Functions\when('home_url')->justReturn('http://datalumo-wp.test/'); + Functions\when('admin_url')->alias(fn (string $path) => 'http://datalumo-wp.test/wp-admin/'.$path); + Functions\when('wp_parse_url')->alias(fn (string $url, int $component = -1) => parse_url($url, $component)); + + $_POST = ['api_url' => 'https://dl.test']; + + $redirected = null; + Functions\expect('wp_redirect')->once()->andReturnUsing(function (string $url) use (&$redirected): void { + $redirected = $url; + throw new RuntimeException('redirect'); + }); + Functions\expect('wp_safe_redirect')->never(); + + try { + Grant::start(); + expect(false)->toBeTrue(); + } catch (RuntimeException $e) { + expect($e->getMessage())->toBe('redirect'); + } + + expect($redirected)->toStartWith('https://dl.test/integrations/wordpress?'); +}); + +it('rejects a callback when the stored state does not match', function () { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('get_transient')->justReturn('expected-state'); + Functions\when('delete_transient')->justReturn(true); + Functions\when('sanitize_text_field')->returnArg(1); + Functions\when('wp_unslash')->returnArg(1); + Functions\when('wp_create_nonce')->justReturn('nonce'); + Functions\when('admin_url')->justReturn('https://shop.example/wp-admin/options-general.php'); + Functions\when('add_query_arg')->alias(fn (array $args, string $url) => $url.'?'.http_build_query($args)); + + $redirected = null; + Functions\when('wp_safe_redirect')->alias(function (string $url) use (&$redirected): void { + $redirected = $url; + throw new RuntimeException('redirect'); + }); + + $_GET = ['state' => 'wrong-state', 'code' => 'abc']; + + try { + Grant::callback(); + expect(false)->toBeTrue(); + } catch (RuntimeException $e) { + expect($e->getMessage())->toBe('redirect'); + } + + expect($redirected)->toContain('grant_failed') + ->and($redirected)->not->toContain('connected'); +}); diff --git a/tests/Unit/IndexPhpTest.php b/tests/Unit/IndexPhpTest.php new file mode 100644 index 0000000..18dcb4e --- /dev/null +++ b/tests/Unit/IndexPhpTest.php @@ -0,0 +1,35 @@ +isDir() || ! in_array($current->getFilename(), $skip, true); + }, + ), + RecursiveIteratorIterator::SELF_FIRST, + ); + + $missing = []; + + foreach (array_merge([$root], iterator_to_array($iterator)) as $file) { + $dir = $file instanceof SplFileInfo + ? ($file->isDir() ? $file->getPathname() : null) + : $file; + + if ($dir === null) { + continue; + } + + $index = $dir.'/index.php'; + + if (! is_file($index) || ! str_contains((string) file_get_contents($index), 'Silence is golden')) { + $missing[] = $dir; + } + } + + expect($missing)->toBe([]); +}); diff --git a/tests/Unit/PagePreparerTest.php b/tests/Unit/PagePreparerTest.php index 31c9c5b..b413063 100644 --- a/tests/Unit/PagePreparerTest.php +++ b/tests/Unit/PagePreparerTest.php @@ -33,6 +33,18 @@ ->and($payload['content_mime'])->toBe('text/html'); }); +it('falls back when block rendering fatals', function () { + Functions\when('get_the_title')->justReturn('Product'); + Functions\when('do_blocks')->alias(function (): string { + throw new Error('Call to undefined function Automattic\WooCommerce\Blocks\Domain\Services\wc_get_notices()'); + }); + + $post = new WP_Post(); + $post->post_content = '

Ceramic mug

'; + + expect((new PagePreparer())->prepare($post)['content'])->toBe('

Ceramic mug

'); +}); + it('skips a post with no content', function () { Functions\when('get_the_title')->justReturn('Empty'); Functions\when('wp_strip_all_tags')->justReturn(''); @@ -42,3 +54,19 @@ expect((new PagePreparer())->prepare($post))->toBeNull(); }); + +it('keeps a payload the filter filled after an empty body', function () { + Functions\when('get_the_title')->justReturn('Mug'); + Functions\when('apply_filters')->alias(function (string $hook, mixed $value) { + if ($hook === 'datalumo_page_payload' && is_array($value)) { + $value['content'] = '

A ceramic mug.

'; + } + + return $value; + }); + + $post = new WP_Post(); + $post->post_content = ''; + + expect((new PagePreparer())->prepare($post)['content'])->toBe('

A ceramic mug.

'); +}); diff --git a/tests/Unit/SettingsPageTest.php b/tests/Unit/SettingsPageTest.php new file mode 100644 index 0000000..775570e --- /dev/null +++ b/tests/Unit/SettingsPageTest.php @@ -0,0 +1,65 @@ +returnArg(1); + + $page = new SettingsPage(); + $method = new ReflectionMethod(SettingsPage::class, 'sanitizeWidgetKey'); + $rejected = new ReflectionProperty(SettingsPage::class, 'widgetKeyRejected'); + + expect($method->invoke($page, $pasted, 'org-1/widget-2'))->toBe('org-1/widget-2') + ->and($rejected->getValue($page))->toBeTrue(); +})->with([ + 'dl_secret', + '1|plain-api-token', + 'not-a-key', +]); + +it('does not print a second Settings saved notice', function () { + $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php'); + + expect($view)->not->toContain("esc_html_e('Settings saved.'"); +}); + +it('points settings help at the WordPress plugin docs', function () { + expect(SettingsPage::docsUrl())->toBe('https://datalumo.app/docs/wordpress') + ->and(SettingsPage::docsUrl('chat-page-actions'))->toBe('https://datalumo.app/docs/wordpress#chat-page-actions') + ->and(SettingsPage::helpUrl())->toBe('https://datalumo.app/docs/wordpress') + ->and(SettingsPage::ASK_MAX_LENGTH)->toBe(280); +}); + +it('hides the settings sidebar until setup is ready', function () { + $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php'); + + expect($view)->toContain('datalumo-settings-aside') + ->and(substr_count($view, 'if (SettingsPage::setupIsReady())'))->toBe(2); +}); + +it('hides other tabs until the post-connect checklist is saved', function () { + Options::merge([ + 'api_token' => 'tok_1', + 'organisation' => ['id' => 'org_1'], + 'setup_pending' => true, + ]); + + expect(SettingsPage::setupIsReady())->toBeFalse(); + + Options::set('setup_pending', false); + + expect(SettingsPage::setupIsReady())->toBeTrue(); +}); + +it('accepts a compound widget key', function () { + Functions\when('sanitize_text_field')->returnArg(1); + + $page = new SettingsPage(); + $method = new ReflectionMethod(SettingsPage::class, 'sanitizeWidgetKey'); + $rejected = new ReflectionProperty(SettingsPage::class, 'widgetKeyRejected'); + + expect($method->invoke($page, 'org-1/widget-2', ''))->toBe('org-1/widget-2') + ->and($rejected->getValue($page))->toBeFalse(); +}); diff --git a/tests/Unit/WooCommerceSkuTest.php b/tests/Unit/WooCommerceSkuTest.php new file mode 100644 index 0000000..cad9da3 --- /dev/null +++ b/tests/Unit/WooCommerceSkuTest.php @@ -0,0 +1,230 @@ +id; + } + + public function get_sku(): string + { + return $this->sku; + } + + public function get_short_description(): string + { + return $this->shortDescription; + } + + public function get_attributes(): array + { + return $this->attributes; + } + + public function is_type(string $type): bool + { + return $this->type === $type; + } + + public function get_children(): array + { + return $this->children; + } + }; +} + +function wcAttribute(string $name, array $options, bool $visible = true, bool $taxonomy = false): object +{ + return new class($name, $options, $visible, $taxonomy) + { + public function __construct( + private string $name, + private array $options, + private bool $visible, + private bool $taxonomy, + ) {} + + public function get_name(): string + { + return $this->name; + } + + public function get_visible(): bool + { + return $this->visible; + } + + public function is_taxonomy(): bool + { + return $this->taxonomy; + } + + public function get_options(): array + { + return $this->options; + } + }; +} + +beforeEach(function () { + Functions\when('wp_strip_all_tags')->returnArg(1); + Functions\when('wc_attribute_label')->returnArg(1); + Functions\when('get_the_terms')->justReturn([]); +}); + +it('leaves non-product payloads alone', function () { + $post = new WP_Post(); + $post->post_type = 'page'; + + $payload = ['content' => '

About

', 'meta' => ['post_type' => 'page']]; + + expect((new WooCommerce())->enrichProduct($payload, $post))->toBe($payload); +}); + +it('adds a simple product sku to meta and searchable content', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'VNECK-BLU')); + + $post = new WP_Post(); + $post->ID = 12; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A blue v-neck.

', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['meta']['sku'])->toBe('VNECK-BLU') + ->and($payload['content'])->toContain('sku: VNECK-BLU'); +}); + +it('includes variation skus on a variable product', function () { + Functions\when('wc_get_product')->alias(function (int $id) { + return match ($id) { + 10 => wcProduct(sku: 'jacket', type: 'variable', children: [11, 12], id: 10), + 11 => wcProduct(sku: 'jacket-blue-m', id: 11), + 12 => wcProduct(sku: 'jacket-red-l', id: 12), + default => null, + }; + }); + + $post = new WP_Post(); + $post->ID = 10; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A jacket.

', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['meta']['sku'])->toBe(['jacket', 'jacket-blue-m', 'jacket-red-l']) + ->and($payload['content'])->toContain('sku: jacket, jacket-blue-m, jacket-red-l'); +}); + +it('does not overwrite an existing sku mapping', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'FROM-WC')); + + $post = new WP_Post(); + $post->ID = 3; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

Mapped.

', + 'meta' => ['sku' => 'FROM-MAP'], + ], $post); + + expect($payload['meta']['sku'])->toBe('FROM-MAP') + ->and($payload['content'])->toBe('

Mapped.

'); +}); + +it('prepends the short description when the body is empty', function () { + Functions\when('wc_get_product')->justReturn(wcProduct( + sku: 'MUG-1', + shortDescription: 'A ceramic mug.', + )); + + $post = new WP_Post(); + $post->ID = 4; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['content'])->toStartWith('

A ceramic mug.

') + ->and($payload['content'])->toContain('sku: MUG-1'); +}); + +it('adds product categories and tags as searchable meta', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'SHOE-1')); + Functions\when('get_the_terms')->alias(function (int $id, string $taxonomy) { + return match ($taxonomy) { + 'product_cat' => [ + (object) ['name' => 'Running', 'slug' => 'running'], + (object) ['name' => 'Mens', 'slug' => 'mens'], + ], + 'product_tag' => [ + (object) ['name' => 'Sale', 'slug' => 'sale'], + ], + default => [], + }; + }); + + $post = new WP_Post(); + $post->ID = 8; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

Trail shoe.

', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['meta']['categories'])->toBe(['running', 'mens']) + ->and($payload['meta']['tags'])->toBe(['sale']) + ->and($payload['content'])->toContain('category: Running, Mens') + ->and($payload['content'])->toContain('tag: Sale'); +}); + +it('adds visible attributes and skips hidden ones', function () { + Functions\when('wc_get_product')->justReturn(wcProduct( + sku: 'JKT-1', + attributes: [ + wcAttribute('Color', ['Blue', 'Red']), + wcAttribute('Internal', ['secret'], visible: false), + ], + )); + + $post = new WP_Post(); + $post->ID = 9; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A jacket.

', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['meta']['attributes'])->toBe(['Color' => ['Blue', 'Red']]) + ->and($payload['content'])->toContain('Color: Blue, Red') + ->and($payload['content'])->not->toContain('Internal'); +}); diff --git a/tests/Unit/index.php b/tests/Unit/index.php new file mode 100644 index 0000000..6220032 --- /dev/null +++ b/tests/Unit/index.php @@ -0,0 +1,2 @@ + Date: Sat, 29 Aug 2026 00:20:48 +0900 Subject: [PATCH 7/8] Hide the self-hosted Datalumo URL behind a disclosure. Connect with Datalumo stays the main action. The URL field is still there for self-hosted installs, and opens when a non-cloud URL is already stored. Co-authored-by: Cursor --- readme.txt | 2 +- resources/css/admin.css | 23 +++++++++++++++++++++++ resources/views/settings.php | 19 +++++++++++++------ tests/Unit/SettingsPageTest.php | 8 ++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/readme.txt b/readme.txt index 7045cb9..1b1b3e3 100644 --- a/readme.txt +++ b/readme.txt @@ -65,7 +65,7 @@ Only after you connect and enable a feature. Content sync sends the published po = Can I use a self-hosted Datalumo instance? = -Yes. Set the Datalumo URL on the Connection tab before you connect. +Yes. On the Connection tab, open Using a self-hosted Datalumo? and set the URL before you connect. Manual setup has the same field. = Can the chatbot add products to a WooCommerce cart? = diff --git a/resources/css/admin.css b/resources/css/admin.css index 4558221..74885da 100644 --- a/resources/css/admin.css +++ b/resources/css/admin.css @@ -184,6 +184,29 @@ padding: 0; } +.datalumo-self-host { + margin-top: 1rem; + color: #646970; +} + +.datalumo-self-host summary { + cursor: pointer; + font-size: 13px; +} + +.datalumo-self-host[open] summary { + margin-bottom: 0.5rem; +} + +.datalumo-self-host label { + display: block; + margin-bottom: 0.25rem; +} + +.datalumo-self-host p { + margin: 0; +} + .datalumo-settings-layout { display: flex; flex-wrap: wrap; diff --git a/resources/views/settings.php b/resources/views/settings.php index 4fb7a6c..a0047d5 100644 --- a/resources/views/settings.php +++ b/resources/views/settings.php @@ -169,14 +169,21 @@ class="nav-tab "> + -

- - -

+ +
> + +

+ + +

+
-

diff --git a/tests/Unit/SettingsPageTest.php b/tests/Unit/SettingsPageTest.php index 775570e..ada4439 100644 --- a/tests/Unit/SettingsPageTest.php +++ b/tests/Unit/SettingsPageTest.php @@ -32,6 +32,14 @@ ->and(SettingsPage::ASK_MAX_LENGTH)->toBe(280); }); +it('hides the self-hosted URL behind a disclosure on connect', function () { + $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php'); + + expect($view)->toContain('datalumo-self-host') + ->and($view)->toContain("Using a self-hosted Datalumo?") + ->and($view)->toContain('datalumo-grant-api-url'); +}); + it('hides the settings sidebar until setup is ready', function () { $view = (string) file_get_contents(dirname(__DIR__, 2).'/resources/views/settings.php'); From c73acb13a66eb5bf1561cf3e7746c82d70560cb4 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Sat, 29 Aug 2026 00:40:09 +0900 Subject: [PATCH 8/8] Keep bin out of the GitHub release zip. fresh-site and ensure-index-php are maintainer tools, not something a WordPress site should receive. Co-authored-by: Cursor --- .distignore | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.distignore b/.distignore index 9bb3c44..6d9d17a 100644 --- a/.distignore +++ b/.distignore @@ -5,6 +5,7 @@ .slimm .phpunit.cache tests +bin phpunit.xml composer.lock build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76a7a35..94d00ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,7 @@ jobs: --exclude 'build' \ --exclude '*.zip' \ --exclude 'tests' \ + --exclude 'bin' \ --exclude 'phpunit.xml' \ --exclude '.phpunit.cache' \ --exclude '.DS_Store'