plugin = $plugin; } public function register(): void { add_action('admin_menu', function (): void { add_submenu_page('aiwend', __('WooCommerce AI', 'aiwend'), __('WooCommerce AI', 'aiwend'), Capabilities::USE_WOOCOMMERCE_AI, self::SLUG, [$this, 'render']); }); add_action('admin_post_aiwend_wc_generate', [$this, 'generate']); add_action('admin_post_aiwend_wc_save', [$this, 'save']); add_action('admin_post_aiwend_wc_discard', [$this, 'discard']); add_action('admin_post_aiwend_wc_undo', [$this, 'undo']); add_action('admin_enqueue_scripts', function (): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed if (sanitize_key(Input::get('page')) === self::SLUG) { wp_add_inline_script('aiwend-admin', "document.addEventListener('submit',function(e){var f=e.target;if(!f.hasAttribute||!f.hasAttribute('data-aiwend-busy')){return;}var b=f.querySelector('button[type=submit]');var s=f.querySelector('[data-aiwend-busy-text]');setTimeout(function(){if(b){b.disabled=true;}if(s){s.hidden=false;}f.setAttribute('aria-busy','true');},0);});"); } }, 20); } // ------------------------------------------------------------------ helpers private function mayUse(): bool { return WooCommerceAiModule::wooAvailable() && current_user_can(Capabilities::USE_WOOCOMMERCE_AI); } /** A simple or variable (parent only), existing, non-trashed product the current user may edit; null otherwise (including every forged ID). */ private function product(int $id): ?\WC_Product { if ($id <= 0 || !function_exists('wc_get_product')) { return null; } $p = wc_get_product($id); if (!$p instanceof \WC_Product || !in_array($p->get_type(), ProductContent::TYPES, true) || get_post_type($id) !== 'product' || get_post_status($id) === 'trash' || !current_user_can('edit_post', $id)) { return null; } return $p; } /** @return array{title:string, description:string, short:string} the three editable fields as stored (raw, no display filters) */ private static function values(\WC_Product $p): array { return ['title' => (string) $p->get_name('edit'), 'description' => (string) $p->get_description('edit'), 'short' => (string) $p->get_short_description('edit')]; } private static function hash(string $v): string { return hash('sha256', $v); } private static function requestId(string $token): string { return 'wc_' . substr(hash_hmac('sha256', get_current_blog_id() . '|' . get_current_user_id() . '|' . $token, wp_salt('auth')), 0, 24); } /** @return array|null */ private function proposal(): ?array { $p = get_user_meta(get_current_user_id(), WooCommerceAiRetention::key(get_current_blog_id()), true); if (WooCommerceAiRetention::expired($p, time()) || !isset($p['request_id'], $p['product_id'], $p['base'], $p['proposed'])) { return null; } return $p; } /** * @return never */ private function back(string $code, array $args = []) { wp_safe_redirect(add_query_arg(array_merge(['page' => self::SLUG, 'wc' => $code], $args), admin_url('admin.php'))); exit; } /** @return list */ private function choices(): array { $site = get_current_blog_id(); $out = []; $repo = $this->plugin->connections(); $connections = $repo === null ? [] : $repo->list($site); $this->plugin->ai()->primeConnections($connections); foreach ($connections as $c) { if ($c['status'] !== 'verified') { continue; } foreach ($this->plugin->ai()->pickerModels((int) $c['id']) as $m) { $out[] = ['value' => (int) $c['id'] . ':' . $m['id'], 'label' => (string) $c['label'] . ' · ' . $m['id']]; } } if ($this->plugin->mockProviderEnabled()) { $out[] = ['value' => 'demo', 'label' => __('Simulated demo (no AI provider, no charge)', 'aiwend')]; } return $out; } // ------------------------------------------------------------------ handlers public function generate(): void { if (!$this->mayUse()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_wc_generate'); $productId = absint($_POST['product'] ?? 0); $product = $this->product($productId); if ($product === null) { wp_die(esc_html__('That product is not available for WooCommerce AI.', 'aiwend'), '', ['response' => 403]); } $token = isset($_POST['token']) && is_string($_POST['token']) ? sanitize_key(wp_unslash($_POST['token'])) : ''; // accepted only if exactly 24 lowercase hex characters if (preg_match('/^[a-f0-9]{24}$/', $token) !== 1) { $this->back('invalid', ['product' => $productId]); } $requestId = self::requestId($token); $existing = $this->proposal(); if ($existing !== null && $existing['request_id'] === $requestId) { $this->back('proposal', ['product' => $productId]); // the same submission again: show its result, send nothing } $target = isset($_POST['target']) && is_string($_POST['target']) ? sanitize_text_field(wp_unslash($_POST['target'])) : ''; // then a strict pattern and the site's own verified connections below $site = get_current_blog_id(); $ai = $this->plugin->ai(); $connectionId = null; if ($target === 'demo' && $this->plugin->mockProviderEnabled()) { $model = 'mock-1'; $adapter = new MockAdapter(false, 0); } elseif (preg_match('/^([1-9][0-9]{0,9}):(.{1,128})$/', $target, $m) === 1) { $connectionId = (int) $m[1]; $model = $m[2]; $connection = ($nullsafeVariable1 = $this->plugin->connections()) ? $nullsafeVariable1->find($site, $connectionId) : null; if ($connection === null || $connection['status'] !== 'verified' || !Providers::validModelIdFor((string) $connection['provider'], $model) || !$ai->modelKnown($connectionId, $model)) { $this->back('connection', ['product' => $productId]); } try { $adapter = $ai->adapter($site, $connection); } catch (\Throwable $exception) { $this->back('connection', ['product' => $productId]); } } else { $this->back('connection', ['product' => $productId]); } $values = self::values($product); // Only what the administrator ticked as reference leaves the site, and nothing but these fields: no price, stock, SKU, meta, orders or customers. $send = ['title' => $values['title'], 'type' => $product->get_type()]; if (!empty($_POST['use_description'])) { $send['description'] = $values['description']; } if (!empty($_POST['use_short'])) { $send['short'] = $values['short']; } $tone = sanitize_key(Input::post('tone')); $language = sanitize_key(Input::post('language')); if (!WritingStyle::validTone($tone) || !WritingStyle::validLanguage($language)) { $this->back('invalid', ['product' => $productId]); } $brief = isset($_POST['brief']) && is_string($_POST['brief']) ? sanitize_textarea_field(wp_unslash($_POST['brief'])) : ''; // plain text; validated again by ProductContent, escaped on output try { $result = (new ProductContent($this->plugin->ledger, $this->plugin->audit))->generate($this->plugin->currentPrincipal(), $requestId, $productId, $send, $brief, $adapter, $model, $connectionId, $tone, $language); } catch (ContentStudioException $e) { $this->back($e->reason === 'duplicate' && $this->proposal() !== null ? 'proposal' : self::noticeFor($e->reason), ['product' => $productId]); } WooCommerceAiRetention::store(get_current_user_id(), $site, [ 'request_id' => $requestId, 'product_id' => $productId, 'at' => time(), 'model' => $result['model'], 'simulated' => $target === 'demo', 'tone' => $tone, 'language' => $language, 'base' => array_map([self::class, 'hash'], $values), 'proposed' => ['title' => $result['title'], 'description' => $result['description'], 'short' => $result['short']], ]); $this->back('proposal', ['product' => $productId]); } private static function noticeFor(string $reason): string { switch (true) { case in_array($reason, ['invalid_brief', 'no_budget', 'budget_exceeded', 'paused', 'duplicate', 'outcome_uncertain', 'empty_output'], true): return $reason; case $reason === 'provider_timeout': return 'timeout'; case $reason === 'provider_auth': return 'auth'; case $reason === 'provider_rate_limited' || $reason === 'provider_quota_exhausted': return 'rate'; default: return 'failed'; } } public function save(): void { if (!$this->mayUse()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_wc_save'); if (empty($_POST['confirm'])) { $this->back('unconfirmed'); } $fields = array_filter(map_deep(Input::postList('fields'), 'sanitize_key'), 'is_string'); $selected = array_values(array_intersect(self::FIELDS, $fields)); // each value through sanitize_key, then intersected with the allowlist // Rich text is filtered with wp_kses_post; after a cut to the limit it is filtered again, so a cut tag cannot survive. $rich = static function (string $key, int $max): string { $v = wp_kses_post(Input::post($key)); return mb_strlen($v) > $max ? wp_kses_post(mb_substr($v, 0, $max)) : $v; }; $posted = [ 'title' => isset($_POST['title']) && is_string($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '', 'description' => $rich('description', ContentFormatter::BODY_MAX * 2), 'short' => $rich('short', 4000), ]; $requestId = isset($_POST['request_id']) && is_string($_POST['request_id']) ? sanitize_text_field(wp_unslash($_POST['request_id'])) : ''; [$code, $info] = $this->applyProposal($requestId, $selected, $posted); if ($code === 'forbidden') { wp_die(esc_html__('You are not allowed to change this product.', 'aiwend'), '', ['response' => 403]); } $this->back($code, $code === 'saved' ? ['product' => $info] : ($code === 'conflict' ? ['fields' => implode(',', (array) $info)] : [])); } /** * Runs $work(fresh product) inside one transaction that holds a row lock on the product's post row, so two writers of the same product are * serialized and the second reads what the first committed. $work returns [code, info, wrote]; saved/restored commit, conflict/forbidden/ * save_failed roll back. busy = lock wait timeout, vanished product or a COMMIT whose result is unknown (a retry then sees the values and * reports success without writing again). @param callable(\WC_Product):array{0:string,1:mixed,2:bool} $work @return array{0:string,1:mixed,2:bool} */ private function locked(int $productId, callable $work): array { global $wpdb; static $innodb = null; $innodb ??= (function () use ($wpdb): bool { $rows = $wpdb->get_col($wpdb->prepare('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s, %s)', $wpdb->posts, $wpdb->postmeta)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- engine check, cached return count($rows) === 2 && array_unique(array_map('strtoupper', array_map('strval', $rows))) === ['INNODB']; })(); if (!$innodb) { return ['unsupported', 0, false]; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the save transaction $wpdb->last_error = ''; $row = $wpdb->get_var($wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE ID = %d FOR UPDATE", $productId)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the row lock on this product is the serialization point if ($row === null || $wpdb->last_error !== '') { // lock wait timeout or the product vanished: nothing was written $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return ['busy', 0, false]; } $outcome = ['save_failed', 0, false]; try { clean_post_cache($productId); // read what is committed now, not a cached copy $fresh = $this->product($productId); $outcome = $fresh === null ? ['forbidden', 0, false] : $work($fresh); } catch (\Throwable $exception) { $outcome = ['save_failed', 0, false]; } if (!in_array($outcome[0], ['saved', 'restored'], true)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery clean_post_cache($productId); return $outcome; } if ($wpdb->query('COMMIT') === false) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery return ['busy', 0, false]; // unknown outcome: a retry sees the applied values and reports success } clean_post_cache($productId); return $outcome; } /** Sets only the three content properties through WooCommerce CRUD; status, price, stock, SKU, terms, variations and meta are not touched. @param list $fields @param array{title:string, description:string, short:string} $values */ private static function write(\WC_Product $product, array $fields, array $values): void { if (in_array('title', $fields, true)) { $product->set_name($values['title']); } if (in_array('description', $fields, true)) { $product->set_description($values['description']); } if (in_array('short', $fields, true)) { $product->set_short_description($values['short']); } $product->save(); } /** Conflict-safe, idempotent save of the ticked fields. Returns [code, info]: saved (info = product ID) | conflict (info = fields) | stale | forbidden | nofields | empty | busy | unsupported | save_failed. @param list $selected @param array{title:string, description:string, short:string} $values */ public function applyProposal(string $requestId, array $selected, array $values): array { $proposal = $this->proposal(); if ($proposal === null || !hash_equals((string) $proposal['request_id'], $requestId)) { return ['stale', 0]; } $productId = (int) $proposal['product_id']; if (!$this->mayUse() || $this->product($productId) === null) { return ['forbidden', 0]; // authorization and the product are re-checked now, not trusted from generation time } $selected = array_values(array_intersect(self::FIELDS, $selected)); if ($selected === []) { return ['nofields', 0]; } foreach ($selected as $f) { if (trim(wp_strip_all_tags($values[$f] ?? '')) === '') { return ['empty', 0]; } } $before = []; $outcome = $this->locked($productId, function (\WC_Product $fresh) use ($proposal, $selected, $values, $productId, &$before): array { $current = self::values($fresh); $applied = true; $conflicts = []; foreach ($selected as $f) { $applied = $applied && $current[$f] === $values[$f]; if (!hash_equals((string) ($proposal['base'][$f] ?? ''), self::hash($current[$f]))) { $conflicts[] = $f; } } if ($applied) { return ['saved', $productId, false]; // an earlier save of the same values already committed: nothing to write } if ($conflicts !== []) { return ['conflict', $conflicts, false]; // newer content exists: never overwritten silently } foreach ($selected as $f) { $before[$f] = $current[$f]; } self::write($fresh, $selected, $values); return ['saved', $productId, true]; }); if ($outcome[0] !== 'saved') { return [$outcome[0], $outcome[1]]; } $site = get_current_blog_id(); if ($outcome[2]) { // The previous text is kept for one undo (24 hours, this user and site only, same retention as a proposal). Compare-and-restore later refuses if the product changed again. WooCommerceAiRetention::store(get_current_user_id(), $site, [ 'applied' => true, 'request_id' => $requestId, 'product_id' => $productId, 'at' => time(), 'fields' => $selected, 'before' => $before, 'after' => array_map([self::class, 'hash'], array_intersect_key($values, array_flip($selected))), ]); } else { WooCommerceAiRetention::forget(get_current_user_id(), $site); } $this->plugin->audit->record($site, $this->plugin->currentPrincipal()->actorRef(), 'woocommerce_ai.save', 'product:' . $productId, 'ok', $requestId, implode(',', $selected)); return ['saved', $productId]; } /** The undo record of the current user and site, or null. @return array|null */ private function lastApplied(): ?array { $r = get_user_meta(get_current_user_id(), WooCommerceAiRetention::key(get_current_blog_id()), true); if (WooCommerceAiRetention::expired($r, time()) || empty($r['applied']) || !isset($r['product_id'], $r['before'], $r['after'], $r['fields'])) { return null; } return $r; } /** * Puts back the text that the last save replaced, only if the product still holds exactly what that save wrote (so a later edit by anyone is * never overwritten). Returns [code, info]: restored (info = product ID) | conflict (info = fields) | stale | forbidden | busy | unsupported | save_failed. */ public function undoLast(string $requestId): array { $record = $this->lastApplied(); if ($record === null || !hash_equals((string) $record['request_id'], $requestId)) { return ['stale', 0]; } $productId = (int) $record['product_id']; if (!$this->mayUse() || $this->product($productId) === null) { return ['forbidden', 0]; } $fields = array_values(array_intersect(self::FIELDS, (array) $record['fields'])); $before = array_merge(['title' => '', 'description' => '', 'short' => ''], array_map('strval', (array) $record['before'])); $outcome = $this->locked($productId, function (\WC_Product $fresh) use ($record, $fields, $before, $productId): array { $current = self::values($fresh); $already = true; $conflicts = []; foreach ($fields as $f) { $already = $already && $current[$f] === $before[$f]; if (!hash_equals((string) ($record['after'][$f] ?? ''), self::hash($current[$f]))) { $conflicts[] = $f; } } if ($already) { return ['restored', $productId, false]; // an earlier undo already committed } if ($conflicts !== []) { return ['conflict', $conflicts, false]; } self::write($fresh, $fields, $before); return ['restored', $productId, true]; }); if ($outcome[0] !== 'restored') { return [$outcome[0], $outcome[1]]; } $site = get_current_blog_id(); WooCommerceAiRetention::forget(get_current_user_id(), $site); $this->plugin->audit->record($site, $this->plugin->currentPrincipal()->actorRef(), 'woocommerce_ai.undo', 'product:' . $productId, 'ok', $requestId, implode(',', $fields)); return ['restored', $productId]; } public function undo(): void { if (!$this->mayUse()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_wc_undo'); if (empty($_POST['confirm'])) { $this->back('undo_unconfirmed'); } $requestId = isset($_POST['request_id']) && is_string($_POST['request_id']) ? sanitize_text_field(wp_unslash($_POST['request_id'])) : ''; [$code, $info] = $this->undoLast($requestId); if ($code === 'forbidden') { wp_die(esc_html__('You are not allowed to change this product.', 'aiwend'), '', ['response' => 403]); } $this->back($code === 'restored' ? 'restored' : ($code === 'conflict' ? 'undo_conflict' : $code), $code === 'restored' ? ['product' => $info] : []); } public function discard(): void { if (!$this->mayUse()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_wc_discard'); WooCommerceAiRetention::forget(get_current_user_id(), get_current_blog_id()); $this->back('discarded'); } // ------------------------------------------------------------------ screen private static function notices(): array { return [ 'invalid_brief' => [__('Write a brief of at least 8 characters (up to 2,000) describing what the product copy should say.', 'aiwend'), 'error'], 'invalid' => [__('That form was not valid. Try again.', 'aiwend'), 'error'], 'connection' => [__('Choose one of the listed AI connections and models. If none is listed, check the connection under AI Connections.', 'aiwend'), 'error'], 'no_budget' => [__('Set a site usage limit first (Usage page). WooCommerce AI only works within your usage limit.', 'aiwend'), 'error'], 'budget_exceeded' => [__('Your usage limit does not leave room for this request. Nothing was sent. Raise the limit on the Usage page or wait for the period to reset.', 'aiwend'), 'error'], 'paused' => [__('New AI requests are paused on the Usage page. Nothing was sent.', 'aiwend'), 'error'], 'duplicate' => [__('That request was already submitted. If it is still running, wait for it to finish and reload this page.', 'aiwend'), 'warn'], 'outcome_uncertain' => [__('The outcome of that request is not known, so it was not repeated and the amount held stays reserved until it is reviewed on the Usage page. Nothing was changed on the product.', 'aiwend'), 'warn'], 'timeout' => [__('The AI provider did not answer in time. The request was not repeated and the usage may be uncertain. Nothing was changed on the product.', 'aiwend'), 'error'], 'auth' => [__('The AI provider refused the connection’s key. Check the connection under AI Connections.', 'aiwend'), 'error'], 'rate' => [__('The AI provider is limiting requests or the provider account has no quota. Try again later.', 'aiwend'), 'error'], 'failed' => [__('The AI provider could not produce content. Nothing was changed on the product. See Usage for the request status.', 'aiwend'), 'error'], 'empty_output' => [__('The AI returned no usable text. Nothing was changed on the product.', 'aiwend'), 'error'], 'stale' => [__('That proposal is no longer available. Generate it again.', 'aiwend'), 'warn'], 'nofields' => [__('Tick at least one field to update.', 'aiwend'), 'error'], 'empty' => [__('A ticked field is empty. Fill it in or untick it. Nothing was changed.', 'aiwend'), 'error'], 'unconfirmed' => [__('Tick the confirmation box to replace the selected fields. Nothing was changed.', 'aiwend'), 'error'], 'busy' => [__('The product is being saved by someone else or the save could not finish. Reload and check the product before trying again.', 'aiwend'), 'warn'], 'unsupported' => [__('Saving product changes needs database tables that support transactions (InnoDB). Nothing was changed.', 'aiwend'), 'error'], 'save_failed' => [__('WooCommerce could not save the product. Nothing was changed.', 'aiwend'), 'error'], 'discarded' => [__('The proposal was discarded.', 'aiwend'), 'success'], 'undo_unconfirmed' => [__('Tick the confirmation box to put the earlier text back. Nothing was changed.', 'aiwend'), 'error'], 'undo_conflict' => [__('The product was edited after the update, so the earlier text was not put back (it would overwrite newer work). Nothing was changed.', 'aiwend'), 'warn'], ]; } public function render(): void { if (!$this->mayUse()) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } nocache_headers(); AdminView::open($this->plugin, self::SLUG, __('WooCommerce AI', 'aiwend'), __('Improve the title and descriptions of a simple or variable product. You review and edit the proposal and choose which fields to replace. Prices, stock, SKU, status, variations and everything else are never changed.', 'aiwend')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameters; no state is changed $code = sanitize_key(Input::get('wc')); $productId = absint($_GET['product'] ?? 0); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $notices = self::notices(); if ($code === 'saved' && $productId > 0) { echo '

' . esc_html__('Product updated.', 'aiwend') . ' ' . esc_html__('Only the selected fields were changed; the product status was not touched.', 'aiwend'); if ($this->product($productId) !== null) { echo ' ' . esc_html__('Edit the product', 'aiwend') . ''; } echo '

'; } elseif ($code === 'restored' && $productId > 0) { echo '

' . esc_html__('Earlier text restored.', 'aiwend') . ' ' . esc_html__('Only the fields that the update replaced were put back; nothing else was touched.', 'aiwend'); if ($this->product($productId) !== null) { echo ' ' . esc_html__('Edit the product', 'aiwend') . ''; } echo '

'; } elseif ($code === 'conflict') { echo ''; } elseif (isset($notices[$code])) { echo '

' . esc_html($notices[$code][0]) . '

'; } $this->undoPanel(); $proposal = $this->proposal(); if ($proposal !== null && $this->product((int) $proposal['product_id']) !== null) { $this->review($proposal); } elseif ($productId > 0 && $this->product($productId) !== null) { $this->briefForm($this->product($productId)); } else { $this->picker(); } AdminView::close(); } /** Offers to put back the text the last update replaced (this user, this site, 24 hours), only while that product is still editable by the user. */ private function undoPanel(): void { $r = $this->lastApplied(); if ($r === null || $this->product((int) $r['product_id']) === null) { return; } $labels = ['title' => __('title', 'aiwend'), 'short' => __('short description', 'aiwend'), 'description' => __('long description', 'aiwend')]; $names = implode(', ', array_map(static fn(string $f): string => $labels[$f] ?? $f, array_values(array_intersect(self::FIELDS, (array) $r['fields'])))); echo '

' . esc_html__('Undo the last update', 'aiwend') . '

' . esc_html(sprintf( /* translators: 1: product ID, 2: list of field names */ __('Product #%1$d: the %2$s was replaced. You can put the earlier text back for 24 hours, as long as the product has not been edited since.', 'aiwend'), (int) $r['product_id'], $names )) . '

'; echo '

'; } private function picker(): void { $term = sanitize_text_field(Input::get('s')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '

' . esc_html__('Choose a product', 'aiwend') . '

' . esc_html__('Only simple and variable products you are allowed to edit are listed. For a variable product only its own title and descriptions are involved; variations, attributes and prices are never read or changed.', 'aiwend') . '

'; echo '

'; $found = wc_get_products(['limit' => 20, 'type' => ProductContent::TYPES, 's' => $term, 'status' => ['publish', 'draft', 'pending', 'private'], 'orderby' => 'modified', 'order' => 'DESC']); $rows = array_filter(is_array($found) ? $found : [], fn($p): bool => $p instanceof \WC_Product && current_user_can('edit_post', $p->get_id())); if ($rows === []) { echo '

' . esc_html__('No matching simple or variable products.', 'aiwend') . '

'; return; } echo ''; } private function briefForm(\WC_Product $product): void { $site = get_current_blog_id(); $choices = $this->choices(); $balance = $this->plugin->ledger->balance($site, 'site'); echo '

' . esc_html__('New proposal', 'aiwend') . '

'; echo '

' . esc_html($product->get_name('edit')) . ' #' . (int) $product->get_id() . ' · ' . esc_html((string) $product->get_status()) . ' · ' . esc_html__('choose another product', 'aiwend') . '

'; echo '

' . esc_html__('What is sent to the AI connection you choose: the product name, your brief and, only if you tick them below, the current descriptions as reference. Nothing else about the product, and no orders, customers, payments, prices, stock or notes. In the simulated demo nothing leaves this site. AI text can be wrong or invented: check every fact before you save.', 'aiwend') . '

'; if ($choices === []) { echo '

' . esc_html__('There is no AI connection ready to use.', 'aiwend') . ' ' . esc_html__('Add or check a connection', 'aiwend') . '

'; return; } if ($balance === null || (int) ($balance['limit'] ?? 0) <= 0) { echo '

' . esc_html__('No usage limit is set, so nothing can be generated yet.', 'aiwend') . ' ' . esc_html__('Set a limit', 'aiwend') . '

'; return; } echo '
'; echo ''; echo '

' . esc_html__('Requests count against your site usage limit.', 'aiwend') . '

'; echo wp_kses(ContentStudioAdmin::styleFields('aw-wc', '', ''), AdminView::allowedHtml()); echo '

' . esc_html__('Describe the product, audience, tone and facts to include. Up to 2,000 characters. Do not include personal data or anything you would not send to your AI provider.', 'aiwend') . '

'; echo '
' . esc_html__('Use as reference (sent to the provider)', 'aiwend') . '
'; echo '

'; } /** @param array $p */ private function review(array $p): void { $product = $this->product((int) $p['product_id']); if ($product === null) { return; } $current = self::values($product); $changed = []; foreach (self::FIELDS as $f) { $changed[$f] = !hash_equals((string) ($p['base'][$f] ?? ''), self::hash($current[$f])); } echo '

' . esc_html__('Review the proposal', 'aiwend') . '

'; echo '

' . esc_html($current['title']) . ' #' . (int) $product->get_id() . ' · ' . esc_html((string) $product->get_status()) . ' · ' . esc_html__('open in WooCommerce', 'aiwend') . '

'; echo '

' . esc_html__('AI-generated proposal, not reviewed.', 'aiwend') . ' ' . esc_html(!empty($p['simulated']) ? __('This text is from the simulated demo, not a real AI.', 'aiwend') : sprintf( /* translators: %s: model name */ __('Generated by %s. Check facts, names and claims before you save.', 'aiwend'), (string) ($p['model'] ?? '') )) . '

'; if (in_array(true, $changed, true)) { echo ''; } echo '
'; echo ''; $labels = ['title' => __('Product title', 'aiwend'), 'short' => __('Short description', 'aiwend'), 'description' => __('Long description (HTML)', 'aiwend')]; foreach (['title', 'short', 'description'] as $f) { $id = 'aw-wc-' . $f; echo '

'; if ($f === 'title') { echo ''; } else { echo ''; } echo '
' . esc_html__('Current value (what will be replaced)', 'aiwend') . ($changed[$f] ? ' — ' . esc_html__('changed since the proposal', 'aiwend') : '') . '
' . (trim($current[$f]) === '' ? '' . esc_html__('(empty)', 'aiwend') . '' : esc_html(mb_substr($current[$f], 0, 6000))) . '
'; } echo '

'; echo '

'; echo '

'; } }