PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
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-pinecone-documents.php +906 -87 3.2.22 → 3.2.24 View file →
@@ -42,15 +42,21 @@
42 42
43 43 const API_VERSION = '2026-07';
44 44 const TEXT_FIELD = 'text';
45 45 const VECTOR_FIELD = 'embedding';
46 + const CODES_FIELD = 'codes'; // plan 3e83e4: normalised part-number tokens stored beside the text
47 + const CODES_MAX = 60; // per document — a long spec sheet must not bloat the 40 KB metadata cap
48 + const CODE_MIN_LEN = 4; // shorter tokens would match everything
49 + const CODE_MAX_LEN = 32;
46 50 const MIGRATION_KEY = 'mxchat_pinecone_docs_migration';
47 51 const UPSERT_BATCH = 40; // 1536-dim vectors are ~30 KB of JSON each; the request cap is 2 MB
48 52 const MIGRATE_BATCH = 40;
53 + const MIGRATE_STEP_SECONDS = 12; // pages copied per AJAX step until this much wall time is used (3e83e4)
54 + const MIGRATE_RETRIES = 3; // attempts per Pinecone call inside a step, 1 s / 2 s / 4 s apart
49 55 const OPTION = 'mxchat_pinecone_addon_options';
50 56
51 57 /** Fields the retrieval and listing paths ask for (never the vector unless needed). */
52 - const RECORD_FIELDS = array('text', 'source_url', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
58 + const RECORD_FIELDS = array('text', 'source_url', 'title', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
53 59
54 60 public static function clouds() {
55 61 return array('aws' => 'AWS', 'gcp' => 'GCP', 'azure' => 'Azure');
56 62 }
@@ -252,8 +258,17 @@
252 258 }
253 259 }
254 260 $doc[self::TEXT_FIELD] = (string) $text;
255 261 $doc[self::VECTOR_FIELD] = array_values(array_map('floatval', $embedding));
262 + // 3e83e4: the normalised code set travels with every write (new content
263 + // and copied records alike) so a part number typed without its
264 + // separators can still reach the record — see index_codes().
265 + $codes = self::index_codes((string) $text);
266 + if (!empty($codes)) {
267 + $doc[self::CODES_FIELD] = $codes;
268 + } else {
269 + unset($doc[self::CODES_FIELD]);
270 + }
256 271 return $doc;
257 272 }
258 273
259 274 /**
@@ -686,8 +701,12 @@
686 701 /**
687 702 * Text-match filter that requires at least one of the codes. Hyphenated or
688 703 * dotted codes are phrase-matched (the tokenizer splits them, and "20" on
689 704 * its own would match everything); single runs use $match_any.
705 + *
706 + * 3e83e4: a second leg matches the NORMALISED code set stored on each
707 + * document (codes $in …), which is what lets "R165321320" reach a record
708 + * that only ever says "R1653" — see query_codes() for the expansion.
690 709 */
691 710 public static function keyword_filter(array $tokens) {
692 711 $clauses = array();
693 712 foreach ($tokens as $token) {
@@ -693,8 +712,12 @@
693 712 foreach ($tokens as $token) {
694 713 $op = preg_match('/[.\-]/', $token) ? '$match_phrase' : '$match_any';
695 714 $clauses[] = array(self::TEXT_FIELD => array($op => $token));
696 715 }
716 + $query_codes = self::query_codes($tokens);
717 + if (!empty($query_codes)) {
718 + $clauses[] = array(self::CODES_FIELD => array('$in' => $query_codes));
719 + }
697 720 if (empty($clauses)) {
698 721 return null;
699 722 }
700 723 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
@@ -699,8 +722,155 @@
699 722 }
700 723 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
701 724 }
702 725
726 + /** Upper-case, separators stripped: "R1653 213 20" → "R165321320", "hgh25ca" → "HGH25CA". */
727 + public static function normalize_code($code) {
728 + return strtoupper(preg_replace('/[\s.\-_\/]+/u', '', (string) $code));
729 + }
730 +
731 + /**
732 + * Code-like runs in a piece of text, INCLUDING digit groups that follow a
733 + * code separated by a space, hyphen or dot ("R1653 213 20" is one number
734 + * the way Rexroth prints it). Returns [ [head, full], … ] where head is
735 + * the first run and full is the whole joined run, both un-normalised.
736 + */
737 + private static function code_runs($text) {
738 + $runs = array();
739 + $text = (string) $text;
740 + $pattern = '/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)((?:[ .\-]\d{2,6}(?![A-Za-z0-9])){1,3})?(?![A-Za-z0-9])/u';
741 + $offset = 0;
742 + $length = strlen($text);
743 + // 0d7bd4: walked one match at a time. Only a head that IS a code keeps
744 + // its trailing digit groups; a plain word ("part 4471-AB-12") used to
745 + // take "4471" as its tail and the digit-led number was never stored.
746 + while ($offset < $length && preg_match($pattern, $text, $match, PREG_OFFSET_CAPTURE, $offset)) {
747 + $head = rtrim($match[1][0], '.');
748 + $head_end = $match[1][1] + strlen($match[1][0]);
749 + $is_code = strlen($head) >= self::CODE_MIN_LEN && preg_match('/\d/', $head);
750 + if ($is_code) {
751 + $has_letter = (bool) preg_match('/[A-Za-z]/', $head);
752 + $separators = preg_match_all('/[.\-]/', $head);
753 + if (!$has_letter && $separators < 2) {
754 + $is_code = false; // 2024, 26.5, 10-20
755 + }
756 + }
757 + if (!$is_code) {
758 + $offset = $head_end;
759 + continue;
760 + }
761 + $tail = isset($match[2]) && $match[2][1] >= 0 ? $match[2][0] : '';
762 + $runs[] = array($head, $head . $tail);
763 + $offset = $match[0][1] + strlen($match[0][0]);
764 + }
765 + return $runs;
766 + }
767 +
768 + /**
769 + * The normalised token set stored on a document at write time: for every
770 + * code-like run in the text, the separator-stripped form, the leading stem
771 + * (the run before the first separator) and the letters+digits stem
772 + * ("HGH25" of "HGH25CA"). Unique, minimum 4 characters, capped.
773 + */
774 + public static function index_codes($text) {
775 + $codes = array();
776 + foreach (self::code_runs($text) as $run) {
777 + list($head, $full) = $run;
778 + $candidates = array(self::normalize_code($full), self::normalize_code($head));
779 + $stem = preg_split('/[.\-\s]/', $head)[0];
780 + $candidates[] = self::normalize_code($stem);
781 + if (preg_match('/^([A-Za-z]+\d+)/', $head, $sm)) {
782 + $candidates[] = self::normalize_code($sm[1]);
783 + }
784 + foreach ($candidates as $c) {
785 + if (strlen($c) >= self::CODE_MIN_LEN && strlen($c) <= self::CODE_MAX_LEN && preg_match('/\d/', $c)) {
786 + $codes[$c] = true;
787 + }
788 + }
789 + if (count($codes) >= self::CODES_MAX) {
790 + break;
791 + }
792 + }
793 + // 0d7bd4: strval — PHP turns an all-digit array key into an int, and
794 + // Pinecone refuses a list that mixes strings and numbers.
795 + return array_slice(array_map('strval', array_keys($codes)), 0, self::CODES_MAX);
796 + }
797 +
798 + /**
799 + * What a question's tokens expand to for the codes $in filter: the
800 + * stripped form, every prefix of it from 4 characters up (a stored stem
801 + * that is a prefix of what the visitor typed then matches — "R1653" for
802 + * "R165321320") and the letters+digits stem. Plain-language questions
803 + * carry no tokens and return nothing, so they are unchanged.
804 + */
805 + public static function query_codes(array $tokens) {
806 + $codes = array();
807 + foreach ($tokens as $token) {
808 + $full = self::normalize_code($token);
809 + if (strlen($full) < self::CODE_MIN_LEN || !preg_match('/\d/', $full)) {
810 + continue;
811 + }
812 + $full = substr($full, 0, self::CODE_MAX_LEN);
813 + $codes[$full] = true;
814 + for ($len = self::CODE_MIN_LEN; $len < strlen($full); $len++) {
815 + $codes[substr($full, 0, $len)] = true;
816 + }
817 + if (preg_match('/^([A-Z]+\d+)/', $full, $sm) && strlen($sm[1]) >= self::CODE_MIN_LEN) {
818 + $codes[$sm[1]] = true;
819 + }
820 + if (count($codes) >= 200) {
821 + break;
822 + }
823 + }
824 + return array_slice(array_map('strval', array_keys($codes)), 0, 200);
825 + }
826 +
827 + /**
828 + * Order keyword-leg hits so an exact code match outranks a prefix-only
829 + * match: a hit is exact when its stored code set (or its text) carries the
830 + * stripped question token itself. Dense order is kept inside each group.
831 + *
832 + * @return array{hits:array,exact:int,prefix:int}
833 + */
834 + public static function rank_keyword_hits(array $hits, array $tokens) {
835 + $exact_codes = array();
836 + foreach ($tokens as $token) {
837 + $n = self::normalize_code($token);
838 + if (strlen($n) >= self::CODE_MIN_LEN) {
839 + $exact_codes[$n] = true;
840 + }
841 + }
842 + $exact = array();
843 + $prefix = array();
844 + foreach ($hits as $hit) {
845 + $meta = is_array($hit['metadata'] ?? null) ? $hit['metadata'] : array();
846 + $stored = is_array($meta[self::CODES_FIELD] ?? null) ? $meta[self::CODES_FIELD] : array();
847 + $is_exact = false;
848 + foreach ($stored as $code) {
849 + if (isset($exact_codes[strtoupper((string) $code)])) {
850 + $is_exact = true;
851 + break;
852 + }
853 + }
854 + if (!$is_exact && !empty($tokens) && isset($meta[self::TEXT_FIELD])) {
855 + $haystack = self::normalize_code($meta[self::TEXT_FIELD]);
856 + foreach ($exact_codes as $code => $_) {
857 + if ($haystack !== '' && strpos($haystack, $code) !== false) {
858 + $is_exact = true;
859 + break;
860 + }
861 + }
862 + }
863 + $hit['code_match'] = $is_exact ? 'exact' : 'prefix';
864 + if ($is_exact) {
865 + $exact[] = $hit;
866 + } else {
867 + $prefix[] = $hit;
868 + }
869 + }
870 + return array('hits' => array_merge($exact, $prefix), 'exact' => count($exact), 'prefix' => count($prefix));
871 + }
872 +
703 873 /** AND two filters (either may be empty). */
704 874 public static function merge_filters($a, $b) {
705 875 $a = (!empty($a) && is_array($a)) ? $a : null;
706 876 $b = (!empty($b) && is_array($b)) ? $b : null;
@@ -825,8 +995,56 @@
825 995 return $base + array('ok' => true, 'message' => sprintf('"%s" is a document index with full-text search (%s, %d dimensions, %s). Ready to use.', $name, $schema['language'] !== '' ? 'language ' . $schema['language'] : 'full-text search', $schema['dimension'], $schema['metric'] ?: 'cosine'));
826 996 }
827 997
828 998 /**
999 + * The plain-language "that name is taken" message (3e83e4) — never a bare
1000 + * HTTP 409. Carries a free suggestion so the UI can fill it in.
1001 + */
1002 + public static function name_taken_message($name, $suggested = '') {
1003 + $message = sprintf(
1004 + /* translators: %s: Pinecone index name */
1005 + __('An index named "%s" already exists in this Pinecone project. Pick a different name, or paste that index\'s host above and press Check index instead of creating one.', 'mxchat'),
1006 + $name
1007 + );
1008 + if ($suggested !== '') {
1009 + /* translators: %s: suggested Pinecone index name */
1010 + $message .= ' ' . sprintf(__('Suggested free name: %s.', 'mxchat'), $suggested);
1011 + }
1012 + return $message;
1013 + }
1014 +
1015 + /**
1016 + * A free index name derived from the current one: <base>-docs, then
1017 + * <base>-docs-2, -3 … (an existing -docs suffix is not doubled). Lists the
1018 + * project's indexes once. @return array{name:string,taken:bool,existing:array}
1019 + */
1020 + public static function suggest_index_name($base, $api_key) {
1021 + $base = strtolower(sanitize_text_field($base));
1022 + $base = preg_replace('/[^a-z0-9-]+/', '-', $base);
1023 + $base = trim(preg_replace('/-{2,}/', '-', $base), '-');
1024 + $base = preg_replace('/-docs(-\d+)?$/', '', $base);
1025 + if ($base === '') {
1026 + $base = 'mxchat';
1027 + }
1028 + $existing = array();
1029 + $res = self::control_request('GET', 'indexes', null, $api_key);
1030 + if ($res['error'] === '' && is_array($res['data'])) {
1031 + foreach (($res['data']['indexes'] ?? array()) as $index) {
1032 + if (isset($index['name'])) {
1033 + $existing[strtolower((string) $index['name'])] = true;
1034 + }
1035 + }
1036 + }
1037 + $stem = substr($base, 0, 45 - strlen('-docs-99'));
1038 + $stem = rtrim($stem, '-');
1039 + $candidate = $stem . '-docs';
1040 + for ($n = 2; isset($existing[$candidate]) && $n < 100; $n++) {
1041 + $candidate = $stem . '-docs-' . $n;
1042 + }
1043 + return array('name' => $candidate, 'taken' => isset($existing[$base]), 'existing' => array_keys($existing));
1044 + }
1045 +
1046 + /**
829 1047 * Create a document index for the active embedding dimension.
830 1048 * @return array|WP_Error the IndexModel
831 1049 */
832 1050 public static function create_document_index($name, $dimension, $cloud, $region, $language, $api_key) {
@@ -833,8 +1051,16 @@
833 1051 $name = strtolower(sanitize_text_field($name));
834 1052 if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,43}[a-z0-9])?$/', $name)) {
835 1053 return new WP_Error('pinecone_index', 'Index names are 1-45 lowercase letters, digits and hyphens.');
836 1054 }
1055 + // 3e83e4: say so in plain words BEFORE Pinecone can answer 409 — the
1056 + // name field is prefilled with the vector index being migrated away
1057 + // from, so the first click used to collide for exactly that customer.
1058 + $taken = self::describe_index($name, $api_key);
1059 + if (!is_wp_error($taken)) {
1060 + $suggest = self::suggest_index_name($name, $api_key);
1061 + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1062 + }
837 1063 $cloud = array_key_exists($cloud, self::clouds()) ? $cloud : 'aws';
838 1064 $region = sanitize_text_field($region) ?: 'us-east-1';
839 1065 $language = array_key_exists($language, self::languages()) ? $language : 'en';
840 1066 $dimension = (int) $dimension;
@@ -849,8 +1075,12 @@
849 1075 self::VECTOR_FIELD => array('type' => 'dense_vector', 'dimension' => $dimension, 'metric' => 'cosine'),
850 1076 )),
851 1077 );
852 1078 $res = self::control_request('POST', 'indexes', $body, $api_key);
1079 + if ($res['code'] === 409) {
1080 + $suggest = self::suggest_index_name($name, $api_key);
1081 + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1082 + }
853 1083 if ($res['error'] !== '' || !is_array($res['data'])) {
854 1084 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty create response', array('code' => $res['code']));
855 1085 }
856 1086 return $res['data'];
@@ -870,29 +1100,55 @@
870 1100 }
871 1101
872 1102 /** Dimension of the active embedding model (same table the KB screen uses). */
873 1103 public static function expected_dimension() {
1104 + $dimension = self::expected_dimension_checked();
1105 + return is_wp_error($dimension) ? 0 : (int) $dimension;
1106 + }
1107 +
1108 + /**
1109 + * 418afd: the dimension the site's embeddings really have, or why it is
1110 + * not known. A Custom Provider embedding model is asked for its size
1111 + * (MxChat_Utils::expected_embedding_dimension()); the standard dropdown is
1112 + * disabled on such a site and used to size the index anyway. The filter
1113 + * still has the last word, and can supply a number when the model could
1114 + * not be asked.
1115 + *
1116 + * @param bool $force Ask a custom model again even after a recent failure (the card's buttons).
1117 + * @return int|WP_Error
1118 + */
1119 + public static function expected_dimension_checked($force = false) {
874 1120 $options = get_option('mxchat_options', array());
875 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
876 - $model_dimensions = array(
877 - 'text-embedding-ada-002' => 1536,
878 - 'text-embedding-3-small' => 1536,
879 - 'text-embedding-3-large' => 3072,
880 - 'voyage-2' => 1024,
881 - 'voyage-large-2' => 1536,
882 - 'voyage-3-large' => 2048,
883 - 'gemini-embedding-001' => 1536,
884 - );
885 - if (strpos($selected_model, 'voyage-3-large') === 0) {
886 - return intval($options['voyage_output_dimension'] ?? 2048);
1121 + $options = is_array($options) ? $options : array();
1122 + $custom = isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on';
1123 + $selected_model = $custom
1124 + ? MxChat_Utils::get_selected_embedding_model($options)
1125 + : ($options['embedding_model'] ?? 'text-embedding-ada-002');
1126 + $dimension = MxChat_Utils::expected_embedding_dimension($options, $force);
1127 + if (is_wp_error($dimension)) {
1128 + $supplied = (int) apply_filters('mxchat_pinecone_expected_dimension', 0, $selected_model);
1129 + return $supplied > 0 ? $supplied : $dimension;
887 1130 }
888 - if (strpos($selected_model, 'gemini-embedding') === 0) {
889 - return intval($options['gemini_output_dimension'] ?? 1536);
1131 + if (!$custom && (strpos($selected_model, 'voyage-3-large') === 0 || strpos($selected_model, 'gemini-embedding') === 0)) {
1132 + return (int) $dimension; // adjustable-size models: the owner's own setting, never filtered (as before)
890 1133 }
891 - $dimension = apply_filters('mxchat_pinecone_expected_dimension', $model_dimensions[$selected_model] ?? 1536, $selected_model);
892 - return (int) $dimension;
1134 + return (int) apply_filters('mxchat_pinecone_expected_dimension', $dimension, $selected_model);
893 1135 }
894 1136
1137 + /** Plain-language refusal for the card when a custom model's size is unknown. (Plain __(): the card's JS text-escapes it.) */
1138 + public static function dimension_unknown_message(WP_Error $error, $action) {
1139 + $reason = trim($error->get_error_message());
1140 + $tail = $action === 'create'
1141 + ? __('The index was not created.', 'mxchat')
1142 + : __('The index was not checked.', 'mxchat');
1143 + return sprintf(
1144 + /* translators: 1: the error the embedding endpoint returned, 2: what did not happen */
1145 + __('Could not determine the dimension of your Custom Provider embedding model (%1$s). %2$s Check the Custom Provider embedding settings, then try again.', 'mxchat'),
1146 + $reason !== '' ? $reason : __('no answer from the embedding endpoint', 'mxchat'),
1147 + $tail
1148 + );
1149 + }
1150 +
895 1151 // =====================================================================
896 1152 // Options (raw writes — the registered sanitizer rebuilds the array and
897 1153 // would flip mxchat_use_pinecone on any isset key; the autosave handler
898 1154 // writes this option the same raw way for the same reason)
@@ -937,8 +1193,72 @@
937 1193 public static function migration_clear() {
938 1194 delete_transient(self::MIGRATION_KEY);
939 1195 }
940 1196
1197 + /**
1198 + * 3e83e4: is there an unfinished copy INTO this host? The settings save
1199 + * and the autosave refuse to switch the live chatbot onto a document index
1200 + * while its copy is still running (unless the owner ticks Switch anyway),
1201 + * so a production bot never answers from a half-filled index.
1202 + *
1203 + * @return array|null the running state, or null when nothing blocks
1204 + */
1205 + public static function migration_blocks_switch($host) {
1206 + $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
1207 + $state = self::migration_get();
1208 + if ($host === '' || empty($state) || ($state['status'] ?? '') !== 'running') {
1209 + return null;
1210 + }
1211 + if (strtolower((string) ($state['target_host'] ?? '')) !== $host) {
1212 + return null;
1213 + }
1214 + return $state;
1215 + }
1216 +
1217 + /** Plain-language line for the switch gate: "The copy into X is still running (481 of 4,503 records)…". */
1218 + public static function switch_blocked_message(array $state) {
1219 + return sprintf(
1220 + /* translators: 1: document index name, 2: records copied, 3: records in total */
1221 + __('The copy into "%1$s" is still running (%2$s of %3$s records). Let it finish before switching the chatbot to the document index, or tick Switch anyway to move it now and copy the rest afterwards.', 'mxchat'),
1222 + (string) ($state['target_name'] ?: $state['target_host']),
1223 + number_format_i18n((int) ($state['copied'] ?? 0)),
1224 + number_format_i18n((int) ($state['total'] ?? 0))
1225 + );
1226 + }
1227 +
1228 + /**
1229 + * Run one Pinecone call up to MIGRATE_RETRIES times, 1 s / 2 s / 4 s apart,
1230 + * so a single dropped connection or a 5xx does not end the copy.
1231 + *
1232 + * @param callable $call returns array|true|WP_Error
1233 + * @return array{result:mixed,attempts:int}
1234 + */
1235 + private static function with_retry(callable $call) {
1236 + $result = null;
1237 + $attempts = 0;
1238 + for ($attempt = 1; $attempt <= self::MIGRATE_RETRIES; $attempt++) {
1239 + $attempts = $attempt;
1240 + $result = call_user_func($call);
1241 + if (!is_wp_error($result)) {
1242 + return array('result' => $result, 'attempts' => $attempts);
1243 + }
1244 + if ($attempt < self::MIGRATE_RETRIES) {
1245 + sleep((int) pow(2, $attempt - 1));
1246 + }
1247 + }
1248 + return array('result' => $result, 'attempts' => $attempts);
1249 + }
1250 +
1251 + /** Seconds one AJAX step may spend copying pages (bounded by PHP's own limit). */
1252 + private static function step_budget() {
1253 + $budget = (float) apply_filters('mxchat_pinecone_migrate_step_seconds', self::MIGRATE_STEP_SECONDS);
1254 + $limit = (int) ini_get('max_execution_time');
1255 + if ($limit > 0 && $limit - 8 < $budget) {
1256 + $budget = max(3, $limit - 8);
1257 + }
1258 + return $budget;
1259 + }
1260 +
941 1261 /** Classic GET /vectors/list page. @return array{ids:array,next:string}|WP_Error */
942 1262 public static function classic_list_page($host, $api_key, $namespace, $limit, $token) {
943 1263 $params = array('limit' => max(1, min(100, (int) $limit)));
944 1264 if ((string) $namespace !== '') {
@@ -1015,12 +1335,133 @@
1015 1335 }
1016 1336 return $data;
1017 1337 }
1018 1338
1339 + /** Display key of a namespace in the per-namespace map — Pinecone's console names the default one __default__. */
1340 + public static function ns_key($namespace) {
1341 + return (string) $namespace === '' ? '__default__' : (string) $namespace;
1342 + }
1343 +
1344 + /** Raw namespace for a display key (the default namespace is '' on the wire). */
1345 + public static function ns_raw($key) {
1346 + return (string) $key === '__default__' ? '' : (string) $key;
1347 + }
1348 +
1349 + /** Classic describe_index_stats → [raw namespace => record count], the default namespace as ''. */
1350 + private static function namespace_counts(array $stats) {
1351 + $counts = array();
1352 + foreach ((array) ($stats['namespaces'] ?? array()) as $ns_name => $ns_info) {
1353 + $counts[self::ns_raw($ns_name)] = (int) ($ns_info['vectorCount'] ?? $ns_info['recordCount'] ?? 0);
1354 + }
1355 + return $counts;
1356 + }
1357 +
1019 1358 /**
1359 + * e50d2e: the order namespaces are copied in when every namespace is
1360 + * asked for — the default namespace first, then the rest by name. Only
1361 + * namespaces that hold records are queued.
1362 + */
1363 + private static function namespace_queue(array $counts) {
1364 + $queue = array();
1365 + if (!empty($counts[''])) {
1366 + $queue[] = '';
1367 + }
1368 + $named = array();
1369 + foreach ($counts as $ns => $count) {
1370 + if ($ns !== '' && $count > 0) {
1371 + $named[] = (string) $ns;
1372 + }
1373 + }
1374 + sort($named, SORT_STRING);
1375 + return array_merge($queue, $named);
1376 + }
1377 +
1378 + private static function new_namespace_entry($namespace, $total, $status = 'queued') {
1379 + return array('namespace' => (string) $namespace, 'total' => (int) $total, 'copied' => 0, 'status' => $status, 'target_count' => null);
1380 + }
1381 +
1382 + /**
1383 + * e50d2e: the per-namespace map of a migration state — display key →
1384 + * namespace / total / copied / status / target_count. A state written
1385 + * before this build copied one namespace and carries no map, so one is
1386 + * synthesised from its single namespace; the Delete guard and the step
1387 + * loop read every state through here.
1388 + */
1389 + public static function per_namespace_map(array $state) {
1390 + if (!empty($state['per_namespace']) && is_array($state['per_namespace'])) {
1391 + return $state['per_namespace'];
1392 + }
1393 + $namespace = (string) ($state['namespace'] ?? '');
1394 + $status = ($state['status'] ?? '') === 'running' ? 'running' : (in_array($state['status'] ?? '', array('done', 'source_deleted'), true) ? 'done' : 'queued');
1395 + $entry = self::new_namespace_entry($namespace, (int) ($state['total'] ?? 0), $status);
1396 + $entry['copied'] = (int) ($state['copied'] ?? 0);
1397 + $entry['target_count'] = isset($state['target_count']) ? $state['target_count'] : null;
1398 + return array(self::ns_key($namespace) => $entry);
1399 + }
1400 +
1401 + /**
1402 + * e50d2e: widen a single-namespace copy — running or finished — to every
1403 + * namespace of the source that holds records. Namespaces already copied
1404 + * keep their entry, the rest queue behind the current one, the total
1405 + * becomes the sum, and "other namespaces" empties because none are left
1406 + * out any more. A finished copy with nothing left to queue stays done.
1407 + *
1408 + * @return array|WP_Error state
1409 + */
1410 + private static function extend_to_all_namespaces(array $state, $api_key) {
1411 + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1412 + if (is_wp_error($stats)) {
1413 + return $stats;
1414 + }
1415 + $ns_counts = self::namespace_counts($stats);
1416 + $map = self::per_namespace_map($state);
1417 + $queue = array_values((array) ($state['queue'] ?? array()));
1418 + $current = isset($state['current']) ? (string) $state['current'] : (string) ($state['namespace'] ?? '');
1419 + foreach (self::namespace_queue($ns_counts) as $ns) {
1420 + if (isset($map[self::ns_key($ns)]) || $ns === $current || in_array($ns, $queue, true)) {
1421 + continue;
1422 + }
1423 + $map[self::ns_key($ns)] = self::new_namespace_entry($ns, $ns_counts[$ns]);
1424 + $queue[] = $ns;
1425 + }
1426 + $total = 0;
1427 + foreach ($map as $entry) {
1428 + $total += (int) ($entry['total'] ?? 0);
1429 + }
1430 + $state['per_namespace'] = $map;
1431 + $state['queue'] = $queue;
1432 + $state['current'] = $current;
1433 + $state['namespace'] = $current;
1434 + $state['total'] = $total;
1435 + $state['total_all'] = (int) ($stats['totalVectorCount'] ?? $total);
1436 + $state['other_namespaces'] = array();
1437 + $state['all_namespaces'] = true;
1438 + if (($state['status'] ?? '') === 'done' && !empty($queue)) {
1439 + $next = array_shift($queue);
1440 + $state['queue'] = $queue;
1441 + $state['current'] = $next;
1442 + $state['namespace'] = $next;
1443 + $state['token'] = '';
1444 + $state['status'] = 'running';
1445 + $state['error'] = '';
1446 + $state['error_at'] = 0;
1447 + $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1448 + unset($state['finished']);
1449 + }
1450 + return $state;
1451 + }
1452 +
1453 + /**
1020 1454 * Validate and start (or resume) a migration. @return array|WP_Error state
1455 + *
1456 + * e50d2e: $all_namespaces copies every namespace of the source that holds
1457 + * records — the default namespace first, then the rest by name — each into
1458 + * the same-named namespace of the document index, one after another, in
1459 + * one run. Without it the copy is exactly 3e83e4's: the one configured
1460 + * namespace. Asking for every namespace on a copy that is already running
1461 + * or finished keeps what was copied and queues the rest.
1021 1462 */
1022 - public static function migrate_start($source_host, array $target_cfg, $restart = false) {
1463 + public static function migrate_start($source_host, array $target_cfg, $restart = false, $all_namespaces = false) {
1023 1464 $source_host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $source_host), '/'));
1024 1465 if ($source_host === '') {
1025 1466 return new WP_Error('migrate', 'Enter the host of the vector index to copy from.');
1026 1467 }
@@ -1029,21 +1470,79 @@
1029 1470 }
1030 1471 if ($source_host === strtolower($target_cfg['host'])) {
1031 1472 return new WP_Error('migrate', 'The source and the document index are the same host.');
1032 1473 }
1033 - $existing = self::migration_get();
1034 - if (!$restart && !empty($existing) && ($existing['source_host'] ?? '') === $source_host && ($existing['target_host'] ?? '') === $target_cfg['host'] && ($existing['status'] ?? '') === 'running') {
1035 - return $existing; // resume
1474 + $existing = self::migration_get();
1475 + $same_copy = !empty($existing) && ($existing['source_host'] ?? '') === $source_host && strtolower((string) ($existing['target_host'] ?? '')) === strtolower($target_cfg['host']);
1476 + if (!$restart && $same_copy && ($existing['status'] ?? '') === 'running') {
1477 + // Resume from the recorded cursor — whether the last step ended
1478 + // cleanly or with an error (3e83e4: a second click continues,
1479 + // it never starts over unless asked).
1480 + $existing['error'] = '';
1481 + if ($all_namespaces && empty($existing['all_namespaces'])) {
1482 + $existing = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1483 + if (is_wp_error($existing)) {
1484 + return $existing;
1485 + }
1486 + }
1487 + return self::migration_set($existing);
1036 1488 }
1489 + if (!$restart && $same_copy && $all_namespaces && ($existing['status'] ?? '') === 'done') {
1490 + $extended = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1491 + if (is_wp_error($extended)) {
1492 + return $extended;
1493 + }
1494 + return self::migration_set($extended);
1495 + }
1037 1496 $stats = self::classic_stats($source_host, $target_cfg['api_key']);
1038 1497 if (is_wp_error($stats)) {
1039 1498 return $stats;
1040 1499 }
1041 1500 $namespace = (string) ($target_cfg['namespace'] ?? '');
1042 - if ($namespace !== '') {
1043 - $total = isset($stats['namespaces'][$namespace]['vectorCount']) ? (int) $stats['namespaces'][$namespace]['vectorCount'] : 0;
1501 + // 3e83e4: the total is the SOURCE NAMESPACE's count, never the whole
1502 + // index — the copy lists one namespace, so an index with records in
1503 + // other namespaces used to report "done" with copied < total and no
1504 + // explanation. The other namespaces are recorded for the UI instead.
1505 + $ns_counts = self::namespace_counts($stats);
1506 + $queue = array();
1507 + $per_namespace = array();
1508 + $other_namespaces = array();
1509 + if ($all_namespaces) {
1510 + $queue = self::namespace_queue($ns_counts);
1511 + if (empty($queue)) {
1512 + $queue = array($namespace); // stats listed no namespaces — copy the configured one, as before
1513 + }
1514 + $total = 0;
1515 + foreach ($queue as $ns) {
1516 + if (isset($ns_counts[$ns])) {
1517 + $count = (int) $ns_counts[$ns];
1518 + } elseif ($ns === '' && empty($ns_counts)) {
1519 + $count = (int) ($stats['totalVectorCount'] ?? 0);
1520 + } else {
1521 + $count = 0;
1522 + }
1523 + $per_namespace[self::ns_key($ns)] = self::new_namespace_entry($ns, $count);
1524 + $total += $count;
1525 + }
1526 + $current = (string) array_shift($queue);
1527 + $per_namespace[self::ns_key($current)]['status'] = 'running';
1044 1528 } else {
1045 - $total = (int) ($stats['totalVectorCount'] ?? 0);
1529 + $current = $namespace;
1530 + if (isset($ns_counts[$namespace])) {
1531 + $total = (int) $ns_counts[$namespace];
1532 + } elseif ($namespace === '' && empty($ns_counts)) {
1533 + $total = (int) ($stats['totalVectorCount'] ?? 0);
1534 + } else {
1535 + $total = 0;
1536 + }
1537 + $per_namespace[self::ns_key($namespace)] = self::new_namespace_entry($namespace, $total, 'running');
1538 + // 47c61f: in the copy order (default first, then by name). Pinecone's
1539 + // stats object lists namespaces in a different order call to call.
1540 + foreach (self::namespace_queue($ns_counts) as $ns_name) {
1541 + if ((string) $ns_name !== (string) $namespace) {
1542 + $other_namespaces[self::ns_key($ns_name)] = (int) $ns_counts[$ns_name];
1543 + }
1544 + }
1046 1545 }
1047 1546 $source_dimension = (int) ($stats['dimension'] ?? 0);
1048 1547 $target_index = self::find_index_by_host($target_cfg['host'], $target_cfg['api_key']);
1049 1548 if (is_wp_error($target_index)) {
@@ -1056,20 +1555,33 @@
1056 1555 if ($source_dimension > 0 && $schema['dimension'] > 0 && $source_dimension !== (int) $schema['dimension']) {
1057 1556 return new WP_Error('migrate', sprintf('Dimensions differ (source %d, document index %d) — the vectors cannot be copied. Re-import your content into the document index instead.', $source_dimension, $schema['dimension']));
1058 1557 }
1059 1558 $state = array(
1060 - 'status' => 'running',
1061 - 'source_host' => $source_host,
1062 - 'target_host' => $target_cfg['host'],
1063 - 'target_name' => (string) ($target_index['name'] ?? ''),
1064 - 'namespace' => $namespace,
1065 - 'total' => $total,
1066 - 'copied' => 0,
1067 - 'token' => '',
1068 - 'batches' => 0,
1069 - 'started' => time(),
1070 - 'error' => '',
1071 - 'source_name' => '',
1559 + 'status' => 'running',
1560 + 'source_host' => $source_host,
1561 + 'target_host' => $target_cfg['host'],
1562 + 'target_name' => (string) ($target_index['name'] ?? ''),
1563 + 'namespace' => $current,
1564 + 'total' => $total,
1565 + 'total_all' => (int) ($stats['totalVectorCount'] ?? $total),
1566 + 'other_namespaces' => $other_namespaces,
1567 + // e50d2e: the namespace being copied, the ones still queued and
1568 + // a per-namespace ledger — the Delete guard checks every entry.
1569 + 'all_namespaces' => (bool) $all_namespaces,
1570 + 'current' => $current,
1571 + 'queue' => $queue,
1572 + 'per_namespace' => $per_namespace,
1573 + 'copied' => 0,
1574 + 'skipped' => 0,
1575 + 'token' => '',
1576 + 'batches' => 0,
1577 + 'retries' => 0,
1578 + 'failed_steps' => 0,
1579 + 'started' => time(),
1580 + 'error' => '',
1581 + 'error_at' => 0,
1582 + 'target_count' => null,
1583 + 'source_name' => '',
1072 1584 );
1073 1585 $source_index = self::find_index_by_host($source_host, $target_cfg['api_key']);
1074 1586 if (!is_wp_error($source_index)) {
1075 1587 $state['source_name'] = (string) ($source_index['name'] ?? '');
@@ -1077,10 +1589,15 @@
1077 1589 return self::migration_set($state);
1078 1590 }
1079 1591
1080 1592 /**
1081 - * Copy one page: list → fetch (values + metadata) → documents/upsert.
1082 - * Idempotent — the same ids land in the same place on a re-run.
1593 + * Copy pages until the step's time budget is used or the source is
1594 + * exhausted: list → fetch (values + metadata) → documents/upsert, each
1595 + * call retried with backoff. Idempotent — the same ids land in the same
1596 + * place on a re-run. The cursor is persisted after EVERY page, so a step
1597 + * that dies mid-way (proxy timeout, killed request) loses at most one
1598 + * page of work and the next click continues from the recorded position.
1599 + *
1083 1600 * @return array|WP_Error state
1084 1601 */
1085 1602 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1086 1603 if (($state['status'] ?? '') !== 'running') {
@@ -1085,44 +1602,245 @@
1085 1602 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1086 1603 if (($state['status'] ?? '') !== 'running') {
1087 1604 return $state;
1088 1605 }
1089 - if (($state['target_host'] ?? '') !== ($target_cfg['host'] ?? '')) {
1606 + if (strtolower((string) ($state['target_host'] ?? '')) !== strtolower((string) ($target_cfg['host'] ?? ''))) {
1090 1607 return new WP_Error('migrate', 'The document index host changed since the migration started. Start it again.');
1091 1608 }
1092 - $page = self::classic_list_page($state['source_host'], $target_cfg['api_key'], $state['namespace'], $batch, $state['token']);
1093 - if (is_wp_error($page)) {
1094 - $state['error'] = $page->get_error_message();
1095 - return self::migration_set($state);
1609 + $started = microtime(true);
1610 + $budget = self::step_budget();
1611 + $pages = 0;
1612 + $api_key = $target_cfg['api_key'];
1613 + foreach (array('retries', 'skipped', 'failed_steps', 'error_at') as $counter) {
1614 + $state[$counter] = (int) ($state[$counter] ?? 0); // states written before 3e83e4 lack these
1096 1615 }
1097 - if (!empty($page['ids'])) {
1098 - $vectors = self::classic_fetch($state['source_host'], $target_cfg['api_key'], $state['namespace'], $page['ids']);
1099 - if (is_wp_error($vectors)) {
1100 - $state['error'] = $vectors->get_error_message();
1101 - return self::migration_set($state);
1616 + if (empty($state['per_namespace']) || !is_array($state['per_namespace'])) {
1617 + // e50d2e: a state written before this build copied one namespace and has no ledger
1618 + $state['per_namespace'] = self::per_namespace_map($state);
1619 + $state['current'] = (string) ($state['namespace'] ?? '');
1620 + $state['queue'] = array();
1621 + }
1622 + if (!isset($state['current'])) {
1623 + $state['current'] = (string) ($state['namespace'] ?? '');
1624 + }
1625 + do {
1626 + $source_host = $state['source_host'];
1627 + $namespace = (string) $state['current'];
1628 + $ns_key = self::ns_key($namespace);
1629 + $token = $state['token'];
1630 + // e50d2e: the same namespace on the document side — every bot keeps reading its own
1631 + $page_cfg = $target_cfg;
1632 + $page_cfg['namespace'] = $namespace;
1633 + $listed = self::with_retry(function () use ($source_host, $api_key, $namespace, $batch, $token) {
1634 + return self::classic_list_page($source_host, $api_key, $namespace, $batch, $token);
1635 + });
1636 + $state['retries'] += max(0, $listed['attempts'] - 1);
1637 + $page = $listed['result'];
1638 + if (is_wp_error($page)) {
1639 + return self::migration_fail($state, 'Listing the source index failed after ' . $listed['attempts'] . ' attempts: ' . $page->get_error_message());
1102 1640 }
1103 - $documents = array();
1104 - foreach ($vectors as $id => $vector) {
1105 - if (empty($vector['values']) || !is_array($vector['values'])) {
1106 - continue;
1641 + $ids = $page['ids'];
1642 + if (!empty($ids)) {
1643 + $fetched = self::with_retry(function () use ($source_host, $api_key, $namespace, $ids) {
1644 + return self::classic_fetch($source_host, $api_key, $namespace, $ids);
1645 + });
1646 + $state['retries'] += max(0, $fetched['attempts'] - 1);
1647 + $vectors = $fetched['result'];
1648 + if (is_wp_error($vectors)) {
1649 + return self::migration_fail($state, 'Reading ' . count($ids) . ' records from the source index failed after ' . $fetched['attempts'] . ' attempts: ' . $vectors->get_error_message());
1107 1650 }
1108 - $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1109 - $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1651 + $documents = array();
1652 + $skipped = 0;
1653 + foreach ($vectors as $id => $vector) {
1654 + if (empty($vector['values']) || !is_array($vector['values'])) {
1655 + $skipped++;
1656 + continue;
1657 + }
1658 + $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1659 + $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1660 + }
1661 + $skipped += max(0, count($ids) - count($vectors));
1662 + if (!empty($documents)) {
1663 + $upserted = self::with_retry(function () use ($documents, $page_cfg) {
1664 + return self::upsert($documents, $page_cfg);
1665 + });
1666 + $state['retries'] += max(0, $upserted['attempts'] - 1);
1667 + if (is_wp_error($upserted['result'])) {
1668 + return self::migration_fail($state, 'Writing ' . count($documents) . ' records to the document index failed after ' . $upserted['attempts'] . ' attempts: ' . $upserted['result']->get_error_message());
1669 + }
1670 + }
1671 + $state['copied'] = (int) $state['copied'] + count($documents);
1672 + $state['skipped'] = (int) ($state['skipped'] ?? 0) + $skipped;
1673 + $state['per_namespace'][$ns_key]['copied'] = (int) ($state['per_namespace'][$ns_key]['copied'] ?? 0) + count($documents);
1110 1674 }
1111 - $result = self::upsert($documents, $target_cfg);
1112 - if (is_wp_error($result)) {
1113 - $state['error'] = $result->get_error_message();
1114 - return self::migration_set($state);
1675 + $state['batches'] = (int) $state['batches'] + 1;
1676 + $state['token'] = $page['next'];
1677 + $state['error'] = '';
1678 + $state['error_at'] = 0;
1679 + $pages++;
1680 + if ($page['next'] === '') {
1681 + // This namespace is copied: record what the document index
1682 + // reports for it, then move on to the next queued namespace
1683 + // inside the same time budget (e50d2e), or finish.
1684 + $state['per_namespace'][$ns_key]['status'] = 'done';
1685 + $state['per_namespace'][$ns_key]['finished'] = time();
1686 + $ns_target = self::namespace_count($page_cfg);
1687 + $state['per_namespace'][$ns_key]['target_count'] = $ns_target > 0 ? $ns_target : null;
1688 + $queue = array_values((array) ($state['queue'] ?? array()));
1689 + if (!empty($queue)) {
1690 + $next = (string) array_shift($queue);
1691 + $state['queue'] = $queue;
1692 + $state['current'] = $next;
1693 + $state['namespace'] = $next;
1694 + $state['token'] = '';
1695 + if (!isset($state['per_namespace'][self::ns_key($next)])) {
1696 + $state['per_namespace'][self::ns_key($next)] = self::new_namespace_entry($next, 0);
1697 + }
1698 + $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1699 + self::migration_set($state);
1700 + continue; // re-checks the budget, then lists the next namespace from its first page
1701 + }
1702 + $state['status'] = 'done';
1703 + $state['finished'] = time();
1704 + // The finished state names what both sides report, so the UI
1705 + // can say "5,000 copied; the document index holds 5,000".
1706 + $target_count = 0;
1707 + foreach ($state['per_namespace'] as $entry) {
1708 + $target_count += (int) ($entry['target_count'] ?? 0);
1709 + }
1710 + $state['target_count'] = $target_count > 0 ? $target_count : null;
1711 + $state['target_count_at'] = time();
1712 + break;
1115 1713 }
1116 - $state['copied'] = (int) $state['copied'] + count($documents);
1714 + self::migration_set($state); // cursor persisted per page, not per step
1715 + } while ((microtime(true) - $started) < $budget);
1716 + $state['last_step_pages'] = $pages;
1717 + $state['last_step_seconds'] = round(microtime(true) - $started, 2);
1718 + return self::migration_set($state);
1719 + }
1720 +
1721 + /** Record count of ONE namespace on a document index (0 when not listed) — count() reads the whole index for the default namespace. */
1722 + private static function namespace_count(array $cfg) {
1723 + $stats = self::stats($cfg);
1724 + if (is_wp_error($stats)) {
1725 + return 0;
1117 1726 }
1118 - $state['batches'] = (int) $state['batches'] + 1;
1119 - $state['token'] = $page['next'];
1120 - $state['error'] = '';
1121 - if ($page['next'] === '') {
1122 - $state['status'] = 'done';
1123 - $state['finished'] = time();
1727 + $ns = (string) ($cfg['namespace'] ?? '');
1728 + return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0;
1729 + }
1730 +
1731 + /**
1732 + * e50d2e: namespaces of the OLD index that still hold records without a
1733 + * finished, count-matching copy — read from the live source stats, so a
1734 + * namespace filled after the copy counts too. Empty array = every record
1735 + * on the old index has a copy; the Delete old index button refuses on
1736 + * anything else and names what is missing.
1737 + *
1738 + * @return array|WP_Error display key → ['namespace','source','copied','status']
1739 + */
1740 + public static function uncopied_namespaces(array $state, $api_key) {
1741 + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1742 + if (is_wp_error($stats)) {
1743 + return $stats;
1124 1744 }
1745 + $counts = self::namespace_counts($stats);
1746 + if (empty($counts) && (int) ($stats['totalVectorCount'] ?? 0) > 0) {
1747 + $counts[''] = (int) $stats['totalVectorCount'];
1748 + }
1749 + $map = self::per_namespace_map($state);
1750 + $missing = array();
1751 + foreach ($counts as $ns => $count) {
1752 + if ($count <= 0) {
1753 + continue;
1754 + }
1755 + $key = self::ns_key($ns);
1756 + $entry = isset($map[$key]) && is_array($map[$key]) ? $map[$key] : null;
1757 + if ($entry === null || ($entry['status'] ?? '') !== 'done' || (int) ($entry['copied'] ?? 0) < $count) {
1758 + $missing[$key] = array(
1759 + 'namespace' => (string) $ns,
1760 + 'source' => (int) $count,
1761 + 'copied' => $entry ? (int) ($entry['copied'] ?? 0) : 0,
1762 + 'status' => $entry ? (string) ($entry['status'] ?? '') : 'not copied',
1763 + );
1764 + }
1765 + }
1766 + // Named in the copy order (default first, then by name) — Pinecone's stats object order varies call to call.
1767 + $ordered = array();
1768 + foreach (self::namespace_queue(array_combine(array_map(array(__CLASS__, 'ns_raw'), array_keys($missing)), array_column($missing, 'source')) ?: array()) as $ns) {
1769 + $ordered[self::ns_key($ns)] = $missing[self::ns_key($ns)];
1770 + }
1771 + return $ordered + $missing;
1772 + }
1773 +
1774 + /** Plain-language refusal naming every namespace the copy is missing. (Plain __(): the card's JS text-escapes it.) */
1775 + public static function uncopied_message(array $missing) {
1776 + $parts = array();
1777 + foreach ($missing as $key => $m) {
1778 + $parts[] = sprintf(
1779 + /* translators: 1: namespace name, 2: records copied, 3: records on the old index */
1780 + __('%1$s (%2$s of %3$s records copied)', 'mxchat'),
1781 + $key,
1782 + number_format_i18n((int) $m['copied']),
1783 + number_format_i18n((int) $m['source'])
1784 + );
1785 + }
1786 + return sprintf(
1787 + /* translators: %s: comma-separated namespace names with their counts */
1788 + _n(
1789 + 'Namespace %s on the old index has records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1790 + 'Namespaces %s on the old index have records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1791 + count($missing),
1792 + 'mxchat'
1793 + ),
1794 + implode(', ', $parts)
1795 + );
1796 + }
1797 +
1798 + /**
1799 + * 5872ef: bots that still keep the given Pinecone host as their own (the
1800 + * Multi-Bot add-on stores a host per bot). Core knows nothing about the
1801 + * bots table — the add-on answers the filter, and with it inactive the
1802 + * list is empty. Delete old index refuses while this is not empty.
1803 + *
1804 + * @return array bot id → bot name
1805 + */
1806 + public static function bots_on_host($host) {
1807 + $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
1808 + if ($host === '') {
1809 + return array();
1810 + }
1811 + $bots = apply_filters('mxchat_pinecone_bots_on_host', array(), $host);
1812 + if (!is_array($bots)) {
1813 + return array();
1814 + }
1815 + $named = array();
1816 + foreach ($bots as $bot_id => $name) {
1817 + $name = is_scalar($name) ? trim((string) $name) : '';
1818 + $named[(string) $bot_id] = $name !== '' ? $name : (string) $bot_id;
1819 + }
1820 + return $named;
1821 + }
1822 +
1823 + /** Plain-language refusal naming every bot still on the old index. (Plain __(): the card's JS text-escapes it.) */
1824 + public static function bots_on_host_message(array $bots) {
1825 + return sprintf(
1826 + /* translators: 1: number of bots, 2: comma-separated bot names */
1827 + _n(
1828 + '%1$s bot still uses the old index: %2$s. Move it to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or it will stop answering. The old index stays.',
1829 + '%1$s bots still use the old index: %2$s. Move them to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or they will stop answering. The old index stays.',
1830 + count($bots),
1831 + 'mxchat'
1832 + ),
1833 + number_format_i18n(count($bots)),
1834 + implode(', ', $bots)
1835 + );
1836 + }
1837 +
1838 + /** Record a step failure without ending the copy: status stays running, the cursor stays put. */
1839 + private static function migration_fail(array $state, $message) {
1840 + $state['error'] = (string) $message;
1841 + $state['error_at'] = time();
1842 + $state['failed_steps'] = (int) ($state['failed_steps'] ?? 0) + 1;
1125 1843 return self::migration_set($state);
1126 1844 }
1127 1845
1128 1846 // =====================================================================
@@ -1131,12 +1849,47 @@
1131 1849
1132 1850 public static function boot() {
1133 1851 add_action('wp_ajax_mxchat_pinecone_docs_create_index', array(__CLASS__, 'ajax_create_index'));
1134 1852 add_action('wp_ajax_mxchat_pinecone_docs_check_index', array(__CLASS__, 'ajax_check_index'));
1853 + add_action('wp_ajax_mxchat_pinecone_docs_suggest_name', array(__CLASS__, 'ajax_suggest_name'));
1135 1854 add_action('wp_ajax_mxchat_pinecone_docs_migrate', array(__CLASS__, 'ajax_migrate'));
1136 1855 add_action('wp_ajax_mxchat_pinecone_docs_delete_old_index', array(__CLASS__, 'ajax_delete_old_index'));
1137 1856 }
1138 1857
1858 + /**
1859 + * Suggest a free document-index name for the name in the field (3e83e4).
1860 + * With a host that already answers in the project, the name is that
1861 + * index's own — the owner is pointing at an existing document index, not
1862 + * about to create one.
1863 + */
1864 + public static function ajax_suggest_name() {
1865 + self::ajax_guard();
1866 + $api_key = self::posted_api_key();
1867 + $base = isset($_POST['base']) ? sanitize_text_field(wp_unslash($_POST['base'])) : '';
1868 + $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : '';
1869 + if ($api_key === '') {
1870 + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1871 + }
1872 + if ($host !== '') {
1873 + $index = self::find_index_by_host($host, $api_key);
1874 + if (!is_wp_error($index) && !empty($index['name'])) {
1875 + wp_send_json_success(array('name' => strtolower((string) $index['name']), 'resolved' => true, 'taken' => false, 'base' => strtolower($base)));
1876 + }
1877 + }
1878 + $suggest = self::suggest_index_name($base, $api_key);
1879 + wp_send_json_success(array('name' => $suggest['name'], 'resolved' => false, 'taken' => $suggest['taken'], 'base' => strtolower($base)));
1880 + }
1881 +
1882 + /** The document-index target for a migration call: the host in the form, else the saved one. */
1883 + private static function posted_target_host(array $options) {
1884 + $posted = isset($_POST['target_host']) ? sanitize_text_field(wp_unslash($_POST['target_host'])) : '';
1885 + $posted = strtolower(trim(str_replace(array('https://', 'http://'), '', $posted), '/'));
1886 + if ($posted !== '') {
1887 + return $posted;
1888 + }
1889 + return strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/'));
1890 + }
1891 +
1139 1892 private static function ajax_guard() {
1140 1893 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
1141 1894 if (!current_user_can('manage_options')) {
1142 1895 wp_send_json_error(array('message' => esc_html__('Permission denied.', 'mxchat')), 403);
@@ -1168,12 +1921,21 @@
1168 1921 }
1169 1922 if ($name === '') {
1170 1923 wp_send_json_error(array('message' => esc_html__('Enter an index name first — the new document index will be created under that name.', 'mxchat')));
1171 1924 }
1172 - $dimension = self::expected_dimension();
1925 + $dimension = self::expected_dimension_checked(true);
1926 + if (is_wp_error($dimension)) {
1927 + // 418afd: never size an index from a guess
1928 + wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'create'), 'code' => 'dimension_unknown'));
1929 + }
1173 1930 $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key);
1174 1931 if (is_wp_error($index)) {
1175 - wp_send_json_error(array('message' => $index->get_error_message()));
1932 + $data = $index->get_error_data();
1933 + wp_send_json_error(array(
1934 + 'message' => $index->get_error_message(),
1935 + 'code' => $index->get_error_code(),
1936 + 'suggested' => is_array($data) ? (string) ($data['suggested'] ?? '') : '',
1937 + ));
1176 1938 }
1177 1939 $host = (string) ($index['host'] ?? '');
1178 1940 // Give Pinecone a few seconds — small serverless indexes are usually Ready quickly.
1179 1941 $ready = !empty($index['status']['ready']);
@@ -1186,21 +1948,24 @@
1186 1948 }
1187 1949 }
1188 1950 $options = get_option(self::OPTION, array());
1189 1951 $options = is_array($options) ? $options : array();
1952 + // 3e83e4: creating the index no longer moves the live chatbot. The
1953 + // host and index type are only written when the owner saves the
1954 + // form; here we record that this host passed the check (the save
1955 + // gate) and the settings the index was created with.
1190 1956 $changes = array(
1191 - 'mxchat_pinecone_api_key' => $api_key,
1192 - 'mxchat_pinecone_host' => $host,
1193 - 'mxchat_pinecone_index' => strtolower($name),
1194 - 'mxchat_pinecone_index_type' => 'document',
1195 1957 'mxchat_pinecone_docs_verified_host' => $host,
1196 1958 'mxchat_pinecone_docs_cloud' => array_key_exists($cloud, self::clouds()) ? $cloud : 'aws',
1197 1959 'mxchat_pinecone_docs_region' => $region ?: 'us-east-1',
1198 1960 'mxchat_pinecone_docs_language' => array_key_exists($language, self::languages()) ? $language : 'en',
1199 1961 );
1962 + if (empty($options['mxchat_pinecone_api_key'])) {
1963 + $changes['mxchat_pinecone_api_key'] = $api_key;
1964 + }
1200 1965 // Remember the classic index so Migrate can copy from it.
1201 1966 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
1202 - if ($previous_host !== '' && $previous_host !== $host && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1967 + if ($previous_host !== '' && strtolower($previous_host) !== strtolower($host) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1203 1968 $changes['mxchat_pinecone_vector_host'] = $previous_host;
1204 1969 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
1205 1970 }
1206 1971 self::persist_options($changes);
@@ -1209,11 +1974,12 @@
1209 1974 'name' => strtolower($name),
1210 1975 'dimension' => $dimension,
1211 1976 'ready' => $ready,
1212 1977 'vector_host' => $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''),
1978 + // Plain text: the card's JS escapes it on render (esc_html__ here double-escaped the quotes to a literal &quot;).
1213 1979 'message' => $ready
1214 - ? sprintf(esc_html__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). Host filled in and saved.', 'mxchat'), strtolower($name), $dimension)
1215 - : sprintf(esc_html__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in and saved; run Check index in a minute to confirm it is ready.', 'mxchat'), strtolower($name), $dimension),
1980 + ? sprintf(__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). The host is filled in above. Your chatbot keeps answering from the current index until you save the Pinecone settings — copy your records across with Migrate first.', 'mxchat'), strtolower($name), $dimension)
1981 + : sprintf(__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in above; run Check index in a minute to confirm it is ready. Your chatbot keeps answering from the current index until you save the Pinecone settings.', 'mxchat'), strtolower($name), $dimension),
1216 1982 ));
1217 1983 }
1218 1984
1219 1985 /** Check index: assert the host is a document index MxChat can use. */
@@ -1224,9 +1990,13 @@
1224 1990 $host = trim(str_replace(array('https://', 'http://'), '', $host), '/');
1225 1991 if ($api_key === '' || $host === '') {
1226 1992 wp_send_json_error(array('message' => esc_html__('Enter the Pinecone API key and host first.', 'mxchat')));
1227 1993 }
1228 - $check = self::check_document_index($host, $api_key, self::expected_dimension());
1994 + $dimension = self::expected_dimension_checked(true);
1995 + if (is_wp_error($dimension)) {
1996 + wp_send_json_error(array('ok' => false, 'message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown'));
1997 + }
1998 + $check = self::check_document_index($host, $api_key, $dimension);
1229 1999 if (!$check['ok']) {
1230 2000 wp_send_json_error($check);
1231 2001 }
1232 2002 $options = get_option(self::OPTION, array());
@@ -1244,15 +2014,23 @@
1244 2014 $check['vector_host'] = $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? '');
1245 2015 wp_send_json_success($check);
1246 2016 }
1247 2017
1248 - /** Migrate: mode start | step | status | reset. Each step copies one page. */
2018 + /**
2019 + * Migrate: mode start | step | status | reset.
2020 + *
2021 + * 3e83e4: the copy is DECOUPLED from the live retrieval path. The target
2022 + * is the document-index host in the form (posted as target_host) plus the
2023 + * API key, checked against Pinecone at start — the saved index type is
2024 + * never consulted, so the site keeps answering from its vector index for
2025 + * the whole copy and the owner switches by saving the settings afterwards.
2026 + * Each step copies pages for up to MIGRATE_STEP_SECONDS.
2027 + */
1249 2028 public static function ajax_migrate() {
1250 2029 self::ajax_guard();
1251 2030 $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'status';
1252 2031 $options = get_option(self::OPTION, array());
1253 2032 $options = is_array($options) ? $options : array();
1254 - $target = self::cfg_from_options($options);
1255 2033 if ($mode === 'reset') {
1256 2034 self::migration_clear();
1257 2035 wp_send_json_success(array('state' => array()));
1258 2036 }
@@ -1258,25 +2036,41 @@
1258 2036 }
1259 2037 if ($mode === 'status') {
1260 2038 wp_send_json_success(array('state' => self::migration_get()));
1261 2039 }
1262 - if ($target['index_type'] !== 'document') {
1263 - wp_send_json_error(array('message' => esc_html__('Save the Pinecone settings with the index type set to Document index first.', 'mxchat')));
2040 + $api_key = self::posted_api_key();
2041 + $namespace = (string) ($options['mxchat_pinecone_namespace'] ?? '');
2042 + if ($api_key === '') {
2043 + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1264 2044 }
1265 - if (strtolower((string) ($options['mxchat_pinecone_docs_verified_host'] ?? '')) !== strtolower($target['host'])) {
1266 - wp_send_json_error(array('message' => esc_html__('Run Check index on the document index host before migrating.', 'mxchat')));
1267 - }
1268 2045 if ($mode === 'start') {
2046 + $target_host = self::posted_target_host($options);
2047 + if ($target_host === '') {
2048 + wp_send_json_error(array('message' => esc_html__('Enter the host of the document index to copy into (or create one with Create index for me).', 'mxchat')));
2049 + }
2050 + $dimension = self::expected_dimension_checked(true);
2051 + if (is_wp_error($dimension)) {
2052 + wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown'));
2053 + }
2054 + $check = self::check_document_index($target_host, $api_key, $dimension);
2055 + if (!$check['ok']) {
2056 + wp_send_json_error(array('message' => $check['message']));
2057 + }
2058 + $target = array('api_key' => $api_key, 'host' => strtolower($check['host']), 'namespace' => $namespace, 'index_type' => 'document');
1269 2059 $source_host = isset($_POST['source_host']) ? sanitize_text_field(wp_unslash($_POST['source_host'])) : '';
1270 2060 $restart = !empty($_POST['restart']);
1271 - $state = self::migrate_start($source_host, $target, $restart);
2061 + $all_ns = !empty($_POST['all_namespaces']); // e50d2e: Copy every namespace
2062 + $state = self::migrate_start($source_host, $target, $restart, $all_ns);
1272 2063 if (is_wp_error($state)) {
1273 2064 wp_send_json_error(array('message' => $state->get_error_message()));
1274 2065 }
1275 - if ($source_host !== '' && $source_host !== (string) ($options['mxchat_pinecone_vector_host'] ?? '')) {
1276 - self::persist_options(array('mxchat_pinecone_vector_host' => $state['source_host'], 'mxchat_pinecone_vector_index' => $state['source_name']));
2066 + $changes = array('mxchat_pinecone_docs_verified_host' => $target['host']);
2067 + if ($source_host !== '' && strtolower($state['source_host']) !== strtolower((string) ($options['mxchat_pinecone_vector_host'] ?? ''))) {
2068 + $changes['mxchat_pinecone_vector_host'] = $state['source_host'];
2069 + $changes['mxchat_pinecone_vector_index'] = $state['source_name'];
1277 2070 }
1278 - wp_send_json_success(array('state' => $state));
2071 + self::persist_options($changes);
2072 + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
1279 2073 }
1280 2074 if ($mode === 'step') {
1281 2075 $state = self::migration_get();
1282 2076 if (empty($state)) {
@@ -1281,8 +2075,9 @@
1281 2075 $state = self::migration_get();
1282 2076 if (empty($state)) {
1283 2077 wp_send_json_error(array('message' => esc_html__('No migration in progress. Click Migrate to start one.', 'mxchat')));
1284 2078 }
2079 + $target = array('api_key' => $api_key, 'host' => (string) ($state['target_host'] ?? ''), 'namespace' => (string) ($state['current'] ?? ($state['namespace'] ?? $namespace)), 'index_type' => 'document');
1285 2080 $state = self::migrate_step($state, $target);
1286 2081 if (is_wp_error($state)) {
1287 2082 wp_send_json_error(array('message' => $state->get_error_message()));
1288 2083 }
@@ -1288,9 +2083,9 @@
1288 2083 }
1289 2084 if (!empty($state['error'])) {
1290 2085 wp_send_json_error(array('message' => $state['error'], 'state' => $state));
1291 2086 }
1292 - wp_send_json_success(array('state' => $state));
2087 + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
1293 2088 }
1294 2089 wp_send_json_error(array('message' => esc_html__('Unknown migration action.', 'mxchat')));
1295 2090 }
1296 2091
@@ -1312,8 +2107,32 @@
1312 2107 wp_send_json_error(array('message' => sprintf(esc_html__('The copy count does not match (%1$d of %2$d copied). The old index stays.', 'mxchat'), (int) $state['copied'], (int) $state['total'])));
1313 2108 }
1314 2109 if ($target['index_type'] !== 'document' || strtolower($target['host']) !== strtolower((string) $state['target_host'])) {
1315 2110 wp_send_json_error(array('message' => esc_html__('The saved document index no longer matches the migration. The old index stays.', 'mxchat')));
2111 + }
2112 + // e50d2e: EVERY namespace that holds records on the old index needs a
2113 + // finished copy whose count matches — not just the one copied last.
2114 + // Before this, a Multi-Bot or legacy-namespace site could delete
2115 + // records that were never copied.
2116 + $missing = self::uncopied_namespaces($state, $target['api_key']);
2117 + // 5872ef: a bot that still keeps the old index as its own host stops
2118 + // answering the moment that index is deleted, however complete the
2119 + // copy is. Refuse and name the bots; when namespaces are uncopied too,
2120 + // say both in one answer.
2121 + $bots = self::bots_on_host((string) ($state['source_host'] ?? ''));
2122 + if (!empty($bots)) {
2123 + $refusal = array('message' => self::bots_on_host_message($bots), 'bots' => $bots);
2124 + if (is_array($missing) && !empty($missing)) {
2125 + $refusal['message'] .= ' ' . self::uncopied_message($missing);
2126 + $refusal['uncopied'] = $missing;
2127 + }
2128 + wp_send_json_error($refusal);
2129 + }
2130 + if (is_wp_error($missing)) {
2131 + wp_send_json_error(array('message' => sprintf(__('Could not read the old index before deleting it (%s). The old index stays.', 'mxchat'), $missing->get_error_message())));
2132 + }
2133 + if (!empty($missing)) {
2134 + wp_send_json_error(array('message' => self::uncopied_message($missing), 'uncopied' => $missing));
1316 2135 }
1317 2136 $source = self::find_index_by_host($state['source_host'], $target['api_key']);
1318 2137 if (is_wp_error($source)) {
1319 2138 wp_send_json_error(array('message' => $source->get_error_message()));