PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | admin/class-knowledge-manager.php +278 -67 3.2.24 → 3.2.25 View file →
@@ -2157,8 +2157,101 @@
2157 2157 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
2158 2158 return ($result !== null) ? $result : $content;
2159 2159 }
2160 2160
2161 +/**
2162 + * Text a page pulls in by REFERENCE, resolved by lookup, never rendered (plan 735943).
2163 + *
2164 + * A synced pattern is stored as a self-closing block comment that names a
2165 + * wp_block post (wp:block with a ref), and theme block shortcodes such as
2166 + * WoodMart's HTML Block name another post by id. The assembler never runs
2167 + * the_content (that would execute every shortcode on the site inside a bulk
2168 + * admin request), so both references stripped to nothing and the chatbot
2169 + * never saw text the visitor reads on the page. Each reference is replaced
2170 + * with the referenced post's own post_content when that post exists, is
2171 + * published and has no password; the usual shortcode strip and tag strip
2172 + * then run over the inlined markup exactly as over the page's own.
2173 + *
2174 + * Guards: depth 3 (a pattern may hold a pattern, which may hold one more),
2175 + * the chain of ids being inlined ends a self-reference or a cycle, and the
2176 + * text inlined per page is capped (mxchat_reference_inline_cap, 200,000
2177 + * characters). A reference that does not resolve is left in place and is
2178 + * removed by the strips exactly as before.
2179 + *
2180 + * The shortcode map is filterable (mxchat_reference_shortcodes): shortcode
2181 + * name => the attribute that carries the post id. Default: html_block / id.
2182 + *
2183 + * @param string $content Raw post_content.
2184 + * @param int $depth 0 at the page itself.
2185 + * @param array $chain Ids being inlined on the current chain (cycle guard).
2186 + * @param int $budget Characters still allowed on this page.
2187 + * @return string
2188 + */
2189 +private function mxchat_inline_referenced_content($content, $depth = 0, $chain = array(), &$budget = null) {
2190 + if (!is_string($content) || $content === '') {
2191 + return (string) $content;
2192 + }
2193 + if ($budget === null) {
2194 + $budget = max(0, (int) apply_filters('mxchat_reference_inline_cap', 200000));
2195 + }
2196 + if ($depth >= 3 || $budget <= 0) {
2197 + return $content;
2198 + }
2199 +
2200 + $self = $this;
2201 + $resolve = function ($id) use ($self, $depth, $chain, &$budget) {
2202 + $id = (int) $id;
2203 + if ($id <= 0 || in_array($id, $chain, true) || $budget <= 0) {
2204 + return null;
2205 + }
2206 + $ref = get_post($id);
2207 + if (!($ref instanceof WP_Post) || $ref->post_status !== 'publish' || (string) $ref->post_password !== '') {
2208 + return null;
2209 + }
2210 + $text = (string) $ref->post_content;
2211 + if (strlen($text) > $budget) {
2212 + $text = function_exists('mb_strcut') ? mb_strcut($text, 0, $budget) : substr($text, 0, $budget);
2213 + }
2214 + $budget -= strlen($text);
2215 + $next = $chain;
2216 + $next[] = $id;
2217 + $r = new ReflectionMethod($self, 'mxchat_inline_referenced_content');
2218 + $r->setAccessible(true);
2219 + return "\n" . $r->invokeArgs($self, array($text, $depth + 1, $next, &$budget)) . "\n";
2220 + };
2221 +
2222 + // 1. Synced patterns: <!-- wp:block {"ref":123} /--> (pattern overrides keep the ref).
2223 + if (strpos($content, 'wp:block') !== false) {
2224 + $content = preg_replace_callback('/<!--\s*wp:block\s+(\{.*?\})\s*\/-->/s', function ($m) use ($resolve) {
2225 + $attrs = json_decode($m[1], true);
2226 + $id = (is_array($attrs) && isset($attrs['ref'])) ? (int) $attrs['ref'] : 0;
2227 + $text = $id ? $resolve($id) : null;
2228 + return ($text === null) ? $m[0] : $text;
2229 + }, $content);
2230 + }
2231 +
2232 + // 2. Reference shortcodes: [html_block id="123"] and whatever the filter adds.
2233 + if (strpos($content, '[') !== false) {
2234 + $map = apply_filters('mxchat_reference_shortcodes', array('html_block' => 'id'));
2235 + foreach ((array) $map as $tag => $attr) {
2236 + if (!is_string($tag) || !is_string($attr) || !preg_match('/^[A-Za-z0-9_-]+$/', $tag) || !preg_match('/^[A-Za-z0-9_-]+$/', $attr)) {
2237 + continue;
2238 + }
2239 + if (strpos($content, '[' . $tag) === false) {
2240 + continue;
2241 + }
2242 + $content = preg_replace_callback('/\[' . preg_quote($tag, '/') . '(\s[^\]]*)?\]/', function ($m) use ($resolve, $attr) {
2243 + $atts = shortcode_parse_atts(isset($m[1]) ? trim(rtrim(trim($m[1]), '/')) : '');
2244 + $id = (is_array($atts) && isset($atts[$attr])) ? (int) $atts[$attr] : 0;
2245 + $text = $id ? $resolve($id) : null;
2246 + return ($text === null) ? $m[0] : $text;
2247 + }, $content);
2248 + }
2249 + }
2250 +
2251 + return $content;
2252 +}
2253 +
2161 2254 public function mxchat_sanitize_content_for_api($content) {
2162 2255 //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
2163 2256
2164 2257 // Remove shortcode tags but PRESERVE content inside them
@@ -2853,9 +2946,10 @@
2853 2946 'full_content' => wp_kses_post(wpautop($display_content)),
2854 2947 'content_length' => mb_strlen($display_content),
2855 2948 'preview_length' => $preview_length,
2856 2949 'source_url' => $entry->source_url,
2857 - 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2950 + 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0 && !MxChat_Utils::is_upload_source($entry->source_url),
2951 + 'source_html' => self::mxchat_source_cell_html($entry->source_url, $entry->bot_id ?? 'default', false),
2858 2952 'chunk_metadata' => $chunk_metadata,
2859 2953 'bot_id' => $entry->bot_id ?? 'default',
2860 2954 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2861 2955 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
@@ -3060,16 +3154,9 @@
3060 3154 </span>
3061 3155 </div>
3062 3156 </td>
3063 3157 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3064 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3065 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3066 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3067 - <?php esc_html_e('View Source', 'mxchat'); ?>
3068 - </a>
3069 - <?php else : ?>
3070 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3071 - <?php endif; ?>
3158 + <?php echo self::mxchat_source_cell_html($source_url, $current_bot_id, true); ?>
3072 3159 </td>
3073 3160 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3074 3161 <button type="button"
3075 3162 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
@@ -3214,16 +3301,10 @@
3214 3301 $actual_source = $source_url;
3215 3302 if (strpos($source_url, '_ungrouped_') === 0) {
3216 3303 $actual_source = $prompt->source_url ?? '';
3217 3304 }
3218 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3219 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3220 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3221 - <?php esc_html_e('View', 'mxchat'); ?>
3222 - </a>
3223 - <?php else : ?>
3224 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3225 - <?php endif; ?>
3305 + echo self::mxchat_source_cell_html($actual_source, $current_bot_id, false);
3306 + ?>
3226 3307 </td>
3227 3308 <td style="padding: 12px 16px; white-space: nowrap;">
3228 3309 <button type="button"
3229 3310 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
@@ -3326,8 +3407,50 @@
3326 3407 * fiction; the page number here is only the cursor step, which the manager
3327 3408 * maps to a stored paginationToken. Used by the page template on first render
3328 3409 * and by the AJAX paginator, so the two can never drift.
3329 3410 */
3411 +/**
3412 + * The Source column of the Knowledge list, one helper for its five render
3413 + * sites (plan b339c0). A web source links; an uploaded file (upload://) is
3414 + * named in plain text ("Uploaded file: manual.pdf, page 3"), and when it has
3415 + * a Source URL that page gets the existing View Source link beside the name;
3416 + * manual content keeps its muted label. esc_url() empties upload://, which
3417 + * used to leave those rows with a View Source link whose address was empty.
3418 + *
3419 + * @param string $source_url Stored source identity.
3420 + * @param string $bot_id Bot the entry belongs to (Source URLs are kept per bot).
3421 + * @param bool $long "View Source" / "Manual Content" (grouped rows) or "View" / "Manual" (single rows).
3422 + * @return string HTML.
3423 + */
3424 +public static function mxchat_source_cell_html($source_url, $bot_id = 'default', $long = true) {
3425 + $view = $long ? __('View Source', 'mxchat') : __('View', 'mxchat');
3426 + $manual = $long ? __('Manual Content', 'mxchat') : __('Manual', 'mxchat');
3427 + $muted = '<span style="color: var(--mxch-text-muted);">%s</span>';
3428 + $link = '<a href="%s" target="_blank" style="color: var(--mxch-primary); text-decoration: none;"><span class="dashicons dashicons-external" style="font-size: 14px;"></span> %s</a>';
3429 +
3430 + if (empty($source_url) || !is_string($source_url) || strpos($source_url, 'mxchat://') === 0 || strpos($source_url, '_ungrouped_') === 0) {
3431 + return sprintf($muted, esc_html($manual));
3432 + }
3433 +
3434 + if (MxChat_Utils::is_upload_source($source_url)) {
3435 + $name = MxChat_Utils::upload_source_name($source_url);
3436 + $page = MxChat_Utils::upload_source_page($source_url);
3437 + $label = $page > 0
3438 + /* translators: 1: file name, 2: page number */
3439 + ? sprintf(__('Uploaded file: %1$s, page %2$d', 'mxchat'), $name, $page)
3440 + /* translators: %s: file name */
3441 + : sprintf(__('Uploaded file: %s', 'mxchat'), $name);
3442 + $html = sprintf($muted, esc_html($label));
3443 + $page_url = MxChat_Utils::get_upload_source_url($source_url, $bot_id);
3444 + if ($page_url !== '') {
3445 + $html .= ' ' . sprintf($link, esc_url($page_url), esc_html($view));
3446 + }
3447 + return $html;
3448 + }
3449 +
3450 + return sprintf($link, esc_url($source_url), esc_html($view));
3451 +}
3452 +
3330 3453 public static function mxchat_cursor_pagination_html($page, $has_next, $search_query = '', $content_type_filter = '') {
3331 3454 $page = max(1, absint($page));
3332 3455 if ($page <= 1 && !$has_next) {
3333 3456 return '';
@@ -3396,12 +3519,18 @@
3396 3519 case 'manual':
3397 3520 $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
3398 3521 break;
3399 3522 case 'pdf':
3400 - $where_clauses[] = "source_url LIKE '%.pdf'";
3523 + // A PDF's pages are stored as <pdf>#page=<n>, so the address
3524 + // alone never ended in .pdf and this filter found no page of
3525 + // any PDF (plan b339c0). The stored content_type is the honest
3526 + // test; the two LIKEs keep rows written before it existed.
3527 + $where_clauses[] = "(content_type = 'pdf' OR source_url LIKE '%.pdf' OR source_url LIKE '%.pdf#page=%')";
3401 3528 break;
3402 3529 case 'url':
3403 - $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
3530 + // Everything with a web address: not manual content, not a PDF
3531 + // page, not an uploaded file (upload://, which has no address).
3532 + $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE 'upload://%' AND NOT (content_type = 'pdf' OR source_url LIKE '%.pdf' OR source_url LIKE '%.pdf#page=%')";
3404 3533 break;
3405 3534 }
3406 3535 }
3407 3536
@@ -3588,16 +3717,9 @@
3588 3717 </span>
3589 3718 </div>
3590 3719 </td>
3591 3720 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3592 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3593 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3594 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3595 - <?php esc_html_e('View Source', 'mxchat'); ?>
3596 - </a>
3597 - <?php else : ?>
3598 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3599 - <?php endif; ?>
3721 + <?php echo self::mxchat_source_cell_html($source_url, $current_bot_id, true); ?>
3600 3722 </td>
3601 3723 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3602 3724 <?php if ($data_source !== 'pinecone') : ?>
3603 3725 <button type="button"
@@ -3733,16 +3855,10 @@
3733 3855 $actual_source = $source_url;
3734 3856 if (strpos($source_url, '_ungrouped_') === 0) {
3735 3857 $actual_source = $prompt->source_url ?? '';
3736 3858 }
3737 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3738 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3739 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3740 - <?php esc_html_e('View', 'mxchat'); ?>
3741 - </a>
3742 - <?php else : ?>
3743 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3744 - <?php endif; ?>
3859 + echo self::mxchat_source_cell_html($actual_source, $current_bot_id, false);
3860 + ?>
3745 3861 </td>
3746 3862 <td style="padding: 12px 16px; white-space: nowrap;">
3747 3863 <button type="button"
3748 3864 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
@@ -6980,18 +7096,20 @@
6980 7096
6981 7097 // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6982 7098 // reading from the FILTERED post object — not re-fetched by ID, which would
6983 7099 // discard it. Auto-sync reads content/excerpt in its historical
6984 - // get_post_field() display context, never appended product custom tabs
6985 - // (its product branch is reachable only with the WooCommerce integration
6986 - // off), and gates ACF→PDF extraction behind its own opt-in option —
6987 - // default OFF, because re-parsing every ACF PDF on every editor save is
6988 - // expensive and most sites don't want it (the 25 MB size cap lives in the
6989 - // shared extractor either way).
7100 + // get_post_field() display context, appends a product's custom tabs
7101 + // (plan f9573e — the bulk import wrote them and this save path dropped them
7102 + // again; its product branch is reachable only with the WooCommerce
7103 + // integration off, the state every store set up from today's screens is in),
7104 + // and gates ACF→PDF extraction behind its own opt-in option — default OFF,
7105 + // because re-parsing every ACF PDF on every editor save is expensive and
7106 + // most sites don't want it (the 25 MB size cap lives in the shared
7107 + // extractor either way).
6990 7108 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6991 7109 'read_display' => true,
6992 7110 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6993 - 'include_product_tabs' => false,
7111 + 'include_product_tabs' => true,
6994 7112 ));
6995 7113 $final_content = $prepared['content'];
6996 7114
6997 7115 // Embedding decision — custom-provider-aware. Gating on a cloud API key
@@ -7035,13 +7153,17 @@
7035 7153 * is deliberate (plan 11720c). Gates BOTH the PDF-id
7036 7154 * collection walk and the extraction loop; the ids are
7037 7155 * only ever read inside the extraction branch, so
7038 7156 * gating collection is output-identical on every install.
7039 - * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
7040 - * tabs to product content; the auto-sync product branch
7041 - * (reachable only with the WooCommerce integration off)
7042 - * never did. Preserved per-path — converging it would be
7043 - * a behavior change, recorded on the plan instead.
7157 + * 'include_product_tabs' bool Append the yikes_woo custom tabs (own + applied
7158 + * reusable) to product content. The bulk path has always
7159 + * done so; the auto-sync product branch (reachable only
7160 + * with the WooCommerce integration off) did not until
7161 + * plan f9573e, so every editor save of a product dropped
7162 + * the tabs its import had written. Both callers pass true
7163 + * now; kb-reimport's 'sync' route mirrors the auto-sync
7164 + * value so "what a save writes today" stays the truth it
7165 + * compares against. Empty text for a product with no tabs.
7044 7166 *
7045 7167 * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
7046 7168 * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
7047 7169 */
@@ -7071,11 +7193,13 @@
7071 7193 if (trim($clean_excerpt) !== '') {
7072 7194 $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
7073 7195 }
7074 7196
7075 - // Main content — remove shortcode tags but preserve content inside them, then
7076 - // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
7077 - $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
7197 + // Main content — resolve text the page pulls in by reference (synced patterns,
7198 + // WoodMart HTML Blocks; plan 735943) by lookup, then remove shortcode tags but
7199 + // preserve content inside them, then strip tags (don't use 'the_content'
7200 + // filter as it may re-add shortcodes and would execute every shortcode).
7201 + $clean_content = $this->strip_shortcode_tags_preserve_content($this->mxchat_inline_referenced_content($raw_content));
7078 7202 $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
7079 7203
7080 7204 // WooCommerce product enrichment (post-fields kind). The WC-object assembler
7081 7205 // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
@@ -7778,8 +7902,12 @@
7778 7902 'store' => $store,
7779 7903 'source' => isset($args['source']) && $args['source'] !== '' ? (string) $args['source'] : 'all',
7780 7904 'before' => $before,
7781 7905 'cap' => isset($args['cap']) ? (int) $args['cap'] : 100,
7906 + // Plan a00ef8: also rewrite entries whose page was edited after import;
7907 + // with the default date they are offered whatever their own date says.
7908 + 'with_edited' => !empty($args['edited']),
7909 + 'before_set' => !empty($args['before_set']),
7782 7910 'cursor' => $store === 'wordpress' ? 0 : '',
7783 7911 'max_id' => $store === 'wordpress' ? (int) $wpdb->get_var("SELECT MAX(id) FROM {$wpdb->prefix}mxchat_system_prompt_content") : 0,
7784 7912 'scan_done' => false,
7785 7913 'scanned' => 0,
@@ -7784,8 +7912,9 @@
7784 7912 'scan_done' => false,
7785 7913 'scanned' => 0,
7786 7914 'unchanged' => 0,
7787 7915 'changed' => 0,
7916 + 'edited' => 0,
7788 7917 'skipped' => 0,
7789 7918 'candidates' => array(),
7790 7919 'notes' => array(),
7791 7920 'pos' => 0,
@@ -7795,10 +7924,10 @@
7795 7924 'updated' => time(),
7796 7925 );
7797 7926 }
7798 7927
7799 -/** True when the entry passes --source and --before. */
7800 -private function mxchat_reimport_selected(array $entry, array $state) {
7928 +/** True when the entry passes --source and --before ($ignore_date: --source only). */
7929 +private function mxchat_reimport_selected(array $entry, array $state, $ignore_date = false) {
7801 7930 $url = (string) $entry['source_url'];
7802 7931 $source = (string) $state['source'];
7803 7932 if ($source === 'upload') {
7804 7933 if (!MxChat_Utils::is_upload_source($url)) {
@@ -7812,19 +7941,40 @@
7812 7941 if (strpos($url, $source) !== 0) {
7813 7942 return false;
7814 7943 }
7815 7944 }
7945 + if ($ignore_date) {
7946 + return true;
7947 + }
7816 7948 $limit = strtotime($state['before'] . ' 00:00:00');
7817 - $when = $entry['updated'];
7949 + $when = $this->mxchat_reimport_entry_time($entry);
7950 + // An entry with no date on record predates the date field: treat it as old.
7951 + return ($when === 0) || ($limit === false) || ($when < $limit);
7952 +}
7953 +
7954 +/** When the entry was last written, as a Unix time; 0 when nothing is on record. */
7955 +private function mxchat_reimport_entry_time(array $entry) {
7956 + $when = $entry['updated'];
7818 7957 if (is_string($when) && $when !== '' && !ctype_digit($when)) {
7819 7958 $when = strtotime($when);
7820 7959 }
7821 - $when = (int) $when;
7822 - // An entry with no date on record predates the date field: treat it as old.
7823 - return ($when === 0) || ($limit === false) || ($when < $limit);
7960 + return (int) $when;
7824 7961 }
7825 7962
7826 7963 /**
7964 + * True when the post, page, product or Media Library file behind an entry was
7965 + * modified after the entry was written (plan a00ef8). With no date on the
7966 + * entry there is nothing to compare, so false.
7967 + */
7968 +private function mxchat_reimport_edited_after(array $entry, $post_id) {
7969 + $when = $this->mxchat_reimport_entry_time($entry);
7970 + if ($when <= 0 || !$post_id) {
7971 + return false;
7972 + }
7973 + return ((int) get_post_modified_time('U', true, (int) $post_id)) > $when;
7974 +}
7975 +
7976 +/**
7827 7977 * One page of entries (not rows: a chunked entry is one entry).
7828 7978 * @return array{entries:array,done:bool}|WP_Error
7829 7979 */
7830 7980 private function mxchat_reimport_list_page(array &$state, $limit) {
@@ -8156,10 +8306,11 @@
8156 8306 $filtered = apply_filters('mxchat_before_process_post', $post, $route === 'sync' ? 'default' : $bot_id);
8157 8307 if (!($filtered instanceof WP_Post)) {
8158 8308 $filtered = $post;
8159 8309 }
8310 + // Same argument sets as the two writers (auto-sync appends product tabs since plan f9573e).
8160 8311 $args = ($route === 'sync')
8161 - ? array('read_display' => true, 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1', 'include_product_tabs' => false)
8312 + ? array('read_display' => true, 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1', 'include_product_tabs' => true)
8162 8313 : array('read_display' => false, 'extract_acf_pdfs' => get_option('mxchat_acf_pdf_extraction', '0') === '1', 'include_product_tabs' => true);
8163 8314 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $filtered, $args);
8164 8315 return array('text' => (string) $prepared['content']);
8165 8316 }
@@ -8248,8 +8399,34 @@
8248 8399
8249 8400 if ($repair === null) {
8250 8401 $result['outcome'] = 'changed';
8251 8402 $result['note'] = __('its source reads differently now, and not because of these fixes', 'mxchat');
8403 + $post_id = isset($fresh['post_id']) ? (int) $fresh['post_id'] : 0;
8404 + $type = (string) $entry['content_type'];
8405 + if ($type === 'url') {
8406 + // Imported by address: nothing on this site says when the page changed.
8407 + $result['note'] .= ' ' . __('(imported by address: no edit date to compare)', 'mxchat');
8408 + } elseif ($post_id && $type !== 'content' && $type !== '' && $this->mxchat_reimport_edited_after($entry, $post_id)) {
8409 + // A post, page, product or Media Library file edited after this entry
8410 + // was written (plan a00ef8): offered as "edited", rewritten only when
8411 + // asked. Text an owner typed under a page's address (type content) is
8412 + // never read as edited: there is no import to compare it with.
8413 + $best = -1;
8414 + foreach ($fresh['texts'] as $text) {
8415 + $text = $this->mxchat_reimport_storable($text);
8416 + $shared = count(array_intersect_key($stored_lines, array_flip(array_filter(array_map('trim', explode("\n", $text)), 'strlen'))));
8417 + if ($shared > $best) {
8418 + $best = $shared;
8419 + $result['fresh'] = $text;
8420 + }
8421 + }
8422 + $result['outcome'] = 'edited';
8423 + $result['reasons'] = array('edited');
8424 + $result['note'] = __('edited since import', 'mxchat');
8425 + $result['title'] = isset($fresh['title']) ? (string) $fresh['title'] : '';
8426 + $result['post_id'] = $post_id;
8427 + $result['is_file'] = !empty($fresh['is_file']);
8428 + }
8252 8429 return $result;
8253 8430 }
8254 8431 $result['outcome'] = 'repair';
8255 8432 $result['reasons'] = $repair['reasons'];
@@ -8360,11 +8537,11 @@
8360 8537 * changed between the scan and the confirm) cannot write something the scan
8361 8538 * did not show.
8362 8539 * @return array{outcome:string,reasons:array,note:string}
8363 8540 */
8364 -private function mxchat_reimport_apply_entry(array $entry, $bot_id, $api_key) {
8541 +private function mxchat_reimport_apply_entry(array $entry, $bot_id, $api_key, $rewrite_edited = false) {
8365 8542 $eval = $this->mxchat_reimport_evaluate($entry, $bot_id);
8366 - if ($eval['outcome'] !== 'repair') {
8543 + if ($eval['outcome'] !== 'repair' && !($rewrite_edited && $eval['outcome'] === 'edited')) {
8367 8544 return array('outcome' => $eval['outcome'], 'reasons' => array(), 'note' => $eval['note']);
8368 8545 }
8369 8546 $role = $this->mxchat_reimport_role($entry);
8370 8547
@@ -8458,18 +8635,30 @@
8458 8635 $state['scan_done'] = true;
8459 8636 break;
8460 8637 }
8461 8638 foreach ($page['entries'] as $entry) {
8462 - if (!$this->mxchat_reimport_selected($entry, $state)) {
8639 + $in_window = $this->mxchat_reimport_selected($entry, $state);
8640 + // "Also rewrite edited entries" with the default date (plan a00ef8): an
8641 + // entry whose page was edited after import is stale whatever its own
8642 + // date says, so it is looked at for that one outcome only.
8643 + $for_edit_only = !$in_window && !empty($state['with_edited']) && empty($state['before_set'])
8644 + && $this->mxchat_reimport_selected($entry, $state, true);
8645 + if (!$in_window && !$for_edit_only) {
8463 8646 continue;
8464 8647 }
8648 + $eval = $this->mxchat_reimport_evaluate($entry, $state['bot']);
8649 + if ($for_edit_only && $eval['outcome'] !== 'edited') {
8650 + continue;
8651 + }
8465 8652 $state['scanned']++;
8466 - $eval = $this->mxchat_reimport_evaluate($entry, $state['bot']);
8467 - if ($eval['outcome'] === 'repair') {
8653 + if ($eval['outcome'] === 'repair' || ($eval['outcome'] === 'edited' && !empty($state['with_edited']))) {
8468 8654 unset($entry['text']);
8469 8655 $entry['reasons'] = $eval['reasons'];
8470 8656 $entry['label'] = $this->mxchat_reimport_label($entry);
8471 8657 $state['candidates'][] = $entry;
8658 + } elseif ($eval['outcome'] === 'edited') {
8659 + $state['edited']++;
8660 + $this->mxchat_reimport_note($state, 'edited', $entry, $eval['note']);
8472 8661 } elseif ($eval['outcome'] === 'unchanged') {
8473 8662 $state['unchanged']++;
8474 8663 } elseif ($eval['outcome'] === 'changed') {
8475 8664 $state['changed']++;
@@ -8499,9 +8688,9 @@
8499 8688 break;
8500 8689 }
8501 8690 $i = $state['pos'];
8502 8691 $entry = $state['candidates'][$i];
8503 - $done = $this->mxchat_reimport_apply_entry($entry, $state['bot'], $api_key);
8692 + $done = $this->mxchat_reimport_apply_entry($entry, $state['bot'], $api_key, !empty($state['with_edited']));
8504 8693 $state['candidates'][$i]['result'] = $done['outcome'];
8505 8694 $state['candidates'][$i]['note'] = $done['note'];
8506 8695 if ($done['outcome'] === 'repaired') {
8507 8696 $state['repaired']++;
@@ -8564,8 +8753,15 @@
8564 8753 *
8565 8754 * [--sleep=<ms>]
8566 8755 * : Pause this many milliseconds after each rewritten entry.
8567 8756 *
8757 + * [--edited]
8758 + * : Also rewrite entries whose post, page, product or Media Library file was
8759 + * edited after the entry was written (for sites that keep auto-sync off).
8760 + * Without it such entries are listed as "Edited since import, not rewritten".
8761 + * With it the default --before date does not apply to them; a --before you
8762 + * give still does. Entries imported by address have no edit date and are left alone.
8763 + *
8568 8764 * [--yes]
8569 8765 * : Proceed when more than 25 entries need re-embedding (API cost gate).
8570 8766 *
8571 8767 * ## EXAMPLES
@@ -8572,8 +8768,9 @@
8572 8768 *
8573 8769 * wp mxchat kb-reimport --dry-run
8574 8770 * wp mxchat kb-reimport --source=https://example.com/docs/ --limit=50
8575 8771 * wp mxchat kb-reimport --before=2026-10-01 --yes
8772 + * wp mxchat kb-reimport --edited --dry-run
8576 8773 */
8577 8774 public function cli_kb_reimport($args, $assoc_args) {
8578 8775 $dry_run = !empty($assoc_args['dry-run']);
8579 8776 $yes = !empty($assoc_args['yes']);
@@ -8587,11 +8784,18 @@
8587 8784 'bot' => isset($assoc_args['bot']) ? $assoc_args['bot'] : 'default',
8588 8785 'source' => isset($assoc_args['source']) ? $assoc_args['source'] : 'all',
8589 8786 'before' => isset($assoc_args['before']) ? $assoc_args['before'] : self::REIMPORT_DEFAULT_BEFORE,
8590 8787 'cap' => 0,
8788 + 'edited' => !empty($assoc_args['edited']),
8789 + 'before_set' => isset($assoc_args['before']),
8591 8790 ));
8592 8791 $stores = array('wordpress' => 'the WordPress database', 'pinecone' => 'Pinecone', 'documents' => 'a Pinecone document index');
8593 8792 WP_CLI::log(sprintf('Checking entries in %s last written before %s (source: %s).', $stores[$state['store']], $state['before'], $state['source']));
8793 + if ($state['with_edited']) {
8794 + WP_CLI::log($state['before_set']
8795 + ? 'Entries whose page was edited after import are rewritten too (within the --before date).'
8796 + : 'Entries whose page was edited after import are rewritten too, whatever their own date.');
8797 + }
8594 8798
8595 8799 while (!$state['scan_done']) {
8596 8800 $this->mxchat_reimport_scan_step($state, microtime(true) + 20);
8597 8801 }
@@ -8598,16 +8802,17 @@
8598 8802 if ($state['error'] !== '') {
8599 8803 WP_CLI::error('The scan stopped: ' . $state['error'] . ' Nothing was changed.');
8600 8804 }
8601 8805
8806 + $kinds = array('changed' => 'Left alone', 'edited' => 'Edited since import, not rewritten', 'skipped' => 'Skipped');
8602 8807 foreach ($state['notes'] as $note) {
8603 - WP_CLI::log(sprintf('%s %s (%s)', $note['kind'] === 'changed' ? 'Left alone' : 'Skipped', $note['label'], $note['note']));
8808 + WP_CLI::log(sprintf('%s %s (%s)', isset($kinds[$note['kind']]) ? $kinds[$note['kind']] : 'Skipped', $note['label'], $note['note']));
8604 8809 }
8605 8810 foreach ($state['candidates'] as $c) {
8606 8811 WP_CLI::log(sprintf('%s %s (%s)', $dry_run ? 'Would rewrite' : 'Will rewrite', $c['label'], implode(', ', $c['reasons'])));
8607 8812 }
8608 8813 $count = count($state['candidates']);
8609 - $summary = sprintf('%d checked: %d to rewrite, %d already correct, %d left alone (source changed), %d skipped.', $state['scanned'], $count, $state['unchanged'], $state['changed'], $state['skipped']);
8814 + $summary = sprintf('%d checked: %d to rewrite, %d already correct, %d left alone (source changed), %d edited since import, %d skipped.', $state['scanned'], $count, $state['unchanged'], $state['changed'], $state['edited'], $state['skipped']);
8610 8815 if ($dry_run) {
8611 8816 WP_CLI::success('Dry run. ' . $summary . ' Nothing was changed.');
8612 8817 return;
8613 8818 }
@@ -8661,8 +8866,10 @@
8661 8866 'scanned' => (int) $state['scanned'],
8662 8867 'to_repair' => count($state['candidates']),
8663 8868 'unchanged' => (int) $state['unchanged'],
8664 8869 'changed' => (int) $state['changed'],
8870 + 'edited' => isset($state['edited']) ? (int) $state['edited'] : 0,
8871 + 'with_edited' => !empty($state['with_edited']),
8665 8872 'skipped' => (int) $state['skipped'],
8666 8873 'pos' => (int) $state['pos'],
8667 8874 'repaired' => (int) $state['repaired'],
8668 8875 'failed' => (int) $state['failed'],
@@ -8698,11 +8905,15 @@
8698 8905 }
8699 8906
8700 8907 if ($op === 'scan') {
8701 8908 if (!empty($_POST['restart']) || empty($state) || $state['phase'] !== 'scan') {
8909 + $before = isset($_POST['before']) ? sanitize_text_field(wp_unslash($_POST['before'])) : self::REIMPORT_DEFAULT_BEFORE;
8702 8910 $state = $this->mxchat_reimport_new_state(array(
8703 - 'bot' => isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default',
8704 - 'before' => isset($_POST['before']) ? sanitize_text_field(wp_unslash($_POST['before'])) : self::REIMPORT_DEFAULT_BEFORE,
8911 + 'bot' => isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default',
8912 + 'before' => $before,
8913 + 'edited' => !empty($_POST['edited']) && $_POST['edited'] !== '0',
8914 + // The card always posts a date; one left at the default is the default.
8915 + 'before_set' => $before !== self::REIMPORT_DEFAULT_BEFORE,
8705 8916 ));
8706 8917 }
8707 8918 $this->mxchat_reimport_scan_step($state, microtime(true) + 8);
8708 8919 update_option(self::REIMPORT_STATE_OPTION, $state, false);