| @@ -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); |