| @@ -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,144 @@ | ||
| 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 | + $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'; | |
| 740 | + if (!preg_match_all($pattern, (string) $text, $m, PREG_SET_ORDER)) { | |
| 741 | + return $runs; | |
| 742 | + } | |
| 743 | + foreach ($m as $match) { | |
| 744 | + $head = rtrim($match[1], '.'); | |
| 745 | + if (strlen($head) < self::CODE_MIN_LEN || !preg_match('/\d/', $head)) { | |
| 746 | + continue; | |
| 747 | + } | |
| 748 | + $has_letter = (bool) preg_match('/[A-Za-z]/', $head); | |
| 749 | + $separators = preg_match_all('/[.\-]/', $head); | |
| 750 | + if (!$has_letter && $separators < 2) { | |
| 751 | + continue; // 2024, 26.5, 10-20 | |
| 752 | + } | |
| 753 | + $tail = isset($match[2]) ? $match[2] : ''; | |
| 754 | + $runs[] = array($head, $head . $tail); | |
| 755 | + } | |
| 756 | + return $runs; | |
| 757 | + } | |
| 758 | + | |
| 759 | + /** | |
| 760 | + * The normalised token set stored on a document at write time: for every | |
| 761 | + * code-like run in the text, the separator-stripped form, the leading stem | |
| 762 | + * (the run before the first separator) and the letters+digits stem | |
| 763 | + * ("HGH25" of "HGH25CA"). Unique, minimum 4 characters, capped. | |
| 764 | + */ | |
| 765 | + public static function index_codes($text) { | |
| 766 | + $codes = array(); | |
| 767 | + foreach (self::code_runs($text) as $run) { | |
| 768 | + list($head, $full) = $run; | |
| 769 | + $candidates = array(self::normalize_code($full), self::normalize_code($head)); | |
| 770 | + $stem = preg_split('/[.\-\s]/', $head)[0]; | |
| 771 | + $candidates[] = self::normalize_code($stem); | |
| 772 | + if (preg_match('/^([A-Za-z]+\d+)/', $head, $sm)) { | |
| 773 | + $candidates[] = self::normalize_code($sm[1]); | |
| 774 | + } | |
| 775 | + foreach ($candidates as $c) { | |
| 776 | + if (strlen($c) >= self::CODE_MIN_LEN && strlen($c) <= self::CODE_MAX_LEN && preg_match('/\d/', $c)) { | |
| 777 | + $codes[$c] = true; | |
| 778 | + } | |
| 779 | + } | |
| 780 | + if (count($codes) >= self::CODES_MAX) { | |
| 781 | + break; | |
| 782 | + } | |
| 783 | + } | |
| 784 | + return array_slice(array_keys($codes), 0, self::CODES_MAX); | |
| 785 | + } | |
| 786 | + | |
| 787 | + /** | |
| 788 | + * What a question's tokens expand to for the codes $in filter: the | |
| 789 | + * stripped form, every prefix of it from 4 characters up (a stored stem | |
| 790 | + * that is a prefix of what the visitor typed then matches — "R1653" for | |
| 791 | + * "R165321320") and the letters+digits stem. Plain-language questions | |
| 792 | + * carry no tokens and return nothing, so they are unchanged. | |
| 793 | + */ | |
| 794 | + public static function query_codes(array $tokens) { | |
| 795 | + $codes = array(); | |
| 796 | + foreach ($tokens as $token) { | |
| 797 | + $full = self::normalize_code($token); | |
| 798 | + if (strlen($full) < self::CODE_MIN_LEN || !preg_match('/\d/', $full)) { | |
| 799 | + continue; | |
| 800 | + } | |
| 801 | + $full = substr($full, 0, self::CODE_MAX_LEN); | |
| 802 | + $codes[$full] = true; | |
| 803 | + for ($len = self::CODE_MIN_LEN; $len < strlen($full); $len++) { | |
| 804 | + $codes[substr($full, 0, $len)] = true; | |
| 805 | + } | |
| 806 | + if (preg_match('/^([A-Z]+\d+)/', $full, $sm) && strlen($sm[1]) >= self::CODE_MIN_LEN) { | |
| 807 | + $codes[$sm[1]] = true; | |
| 808 | + } | |
| 809 | + if (count($codes) >= 200) { | |
| 810 | + break; | |
| 811 | + } | |
| 812 | + } | |
| 813 | + return array_slice(array_keys($codes), 0, 200); | |
| 814 | + } | |
| 815 | + | |
| 816 | + /** | |
| 817 | + * Order keyword-leg hits so an exact code match outranks a prefix-only | |
| 818 | + * match: a hit is exact when its stored code set (or its text) carries the | |
| 819 | + * stripped question token itself. Dense order is kept inside each group. | |
| 820 | + * | |
| 821 | + * @return array{hits:array,exact:int,prefix:int} | |
| 822 | + */ | |
| 823 | + public static function rank_keyword_hits(array $hits, array $tokens) { | |
| 824 | + $exact_codes = array(); | |
| 825 | + foreach ($tokens as $token) { | |
| 826 | + $n = self::normalize_code($token); | |
| 827 | + if (strlen($n) >= self::CODE_MIN_LEN) { | |
| 828 | + $exact_codes[$n] = true; | |
| 829 | + } | |
| 830 | + } | |
| 831 | + $exact = array(); | |
| 832 | + $prefix = array(); | |
| 833 | + foreach ($hits as $hit) { | |
| 834 | + $meta = is_array($hit['metadata'] ?? null) ? $hit['metadata'] : array(); | |
| 835 | + $stored = is_array($meta[self::CODES_FIELD] ?? null) ? $meta[self::CODES_FIELD] : array(); | |
| 836 | + $is_exact = false; | |
| 837 | + foreach ($stored as $code) { | |
| 838 | + if (isset($exact_codes[strtoupper((string) $code)])) { | |
| 839 | + $is_exact = true; | |
| 840 | + break; | |
| 841 | + } | |
| 842 | + } | |
| 843 | + if (!$is_exact && !empty($tokens) && isset($meta[self::TEXT_FIELD])) { | |
| 844 | + $haystack = self::normalize_code($meta[self::TEXT_FIELD]); | |
| 845 | + foreach ($exact_codes as $code => $_) { | |
| 846 | + if ($haystack !== '' && strpos($haystack, $code) !== false) { | |
| 847 | + $is_exact = true; | |
| 848 | + break; | |
| 849 | + } | |
| 850 | + } | |
| 851 | + } | |
| 852 | + $hit['code_match'] = $is_exact ? 'exact' : 'prefix'; | |
| 853 | + if ($is_exact) { | |
| 854 | + $exact[] = $hit; | |
| 855 | + } else { | |
| 856 | + $prefix[] = $hit; | |
| 857 | + } | |
| 858 | + } | |
| 859 | + return array('hits' => array_merge($exact, $prefix), 'exact' => count($exact), 'prefix' => count($prefix)); | |
| 860 | + } | |
| 861 | + | |
| 703 | 862 | /** AND two filters (either may be empty). */ |
| 704 | 863 | public static function merge_filters($a, $b) { |
| 705 | 864 | $a = (!empty($a) && is_array($a)) ? $a : null; |
| 706 | 865 | $b = (!empty($b) && is_array($b)) ? $b : null; |
| @@ -825,8 +984,56 @@ | ||
| 825 | 984 | 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 | 985 | } |
| 827 | 986 | |
| 828 | 987 | /** |
| 988 | + * The plain-language "that name is taken" message (3e83e4) — never a bare | |
| 989 | + * HTTP 409. Carries a free suggestion so the UI can fill it in. | |
| 990 | + */ | |
| 991 | + public static function name_taken_message($name, $suggested = '') { | |
| 992 | + $message = sprintf( | |
| 993 | + /* translators: %s: Pinecone index name */ | |
| 994 | + __('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'), | |
| 995 | + $name | |
| 996 | + ); | |
| 997 | + if ($suggested !== '') { | |
| 998 | + /* translators: %s: suggested Pinecone index name */ | |
| 999 | + $message .= ' ' . sprintf(__('Suggested free name: %s.', 'mxchat'), $suggested); | |
| 1000 | + } | |
| 1001 | + return $message; | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + /** | |
| 1005 | + * A free index name derived from the current one: <base>-docs, then | |
| 1006 | + * <base>-docs-2, -3 … (an existing -docs suffix is not doubled). Lists the | |
| 1007 | + * project's indexes once. @return array{name:string,taken:bool,existing:array} | |
| 1008 | + */ | |
| 1009 | + public static function suggest_index_name($base, $api_key) { | |
| 1010 | + $base = strtolower(sanitize_text_field($base)); | |
| 1011 | + $base = preg_replace('/[^a-z0-9-]+/', '-', $base); | |
| 1012 | + $base = trim(preg_replace('/-{2,}/', '-', $base), '-'); | |
| 1013 | + $base = preg_replace('/-docs(-\d+)?$/', '', $base); | |
| 1014 | + if ($base === '') { | |
| 1015 | + $base = 'mxchat'; | |
| 1016 | + } | |
| 1017 | + $existing = array(); | |
| 1018 | + $res = self::control_request('GET', 'indexes', null, $api_key); | |
| 1019 | + if ($res['error'] === '' && is_array($res['data'])) { | |
| 1020 | + foreach (($res['data']['indexes'] ?? array()) as $index) { | |
| 1021 | + if (isset($index['name'])) { | |
| 1022 | + $existing[strtolower((string) $index['name'])] = true; | |
| 1023 | + } | |
| 1024 | + } | |
| 1025 | + } | |
| 1026 | + $stem = substr($base, 0, 45 - strlen('-docs-99')); | |
| 1027 | + $stem = rtrim($stem, '-'); | |
| 1028 | + $candidate = $stem . '-docs'; | |
| 1029 | + for ($n = 2; isset($existing[$candidate]) && $n < 100; $n++) { | |
| 1030 | + $candidate = $stem . '-docs-' . $n; | |
| 1031 | + } | |
| 1032 | + return array('name' => $candidate, 'taken' => isset($existing[$base]), 'existing' => array_keys($existing)); | |
| 1033 | + } | |
| 1034 | + | |
| 1035 | + /** | |
| 829 | 1036 | * Create a document index for the active embedding dimension. |
| 830 | 1037 | * @return array|WP_Error the IndexModel |
| 831 | 1038 | */ |
| 832 | 1039 | public static function create_document_index($name, $dimension, $cloud, $region, $language, $api_key) { |
| @@ -833,8 +1040,16 @@ | ||
| 833 | 1040 | $name = strtolower(sanitize_text_field($name)); |
| 834 | 1041 | if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,43}[a-z0-9])?$/', $name)) { |
| 835 | 1042 | return new WP_Error('pinecone_index', 'Index names are 1-45 lowercase letters, digits and hyphens.'); |
| 836 | 1043 | } |
| 1044 | + // 3e83e4: say so in plain words BEFORE Pinecone can answer 409 — the | |
| 1045 | + // name field is prefilled with the vector index being migrated away | |
| 1046 | + // from, so the first click used to collide for exactly that customer. | |
| 1047 | + $taken = self::describe_index($name, $api_key); | |
| 1048 | + if (!is_wp_error($taken)) { | |
| 1049 | + $suggest = self::suggest_index_name($name, $api_key); | |
| 1050 | + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name'])); | |
| 1051 | + } | |
| 837 | 1052 | $cloud = array_key_exists($cloud, self::clouds()) ? $cloud : 'aws'; |
| 838 | 1053 | $region = sanitize_text_field($region) ?: 'us-east-1'; |
| 839 | 1054 | $language = array_key_exists($language, self::languages()) ? $language : 'en'; |
| 840 | 1055 | $dimension = (int) $dimension; |
| @@ -849,8 +1064,12 @@ | ||
| 849 | 1064 | self::VECTOR_FIELD => array('type' => 'dense_vector', 'dimension' => $dimension, 'metric' => 'cosine'), |
| 850 | 1065 | )), |
| 851 | 1066 | ); |
| 852 | 1067 | $res = self::control_request('POST', 'indexes', $body, $api_key); |
| 1068 | + if ($res['code'] === 409) { | |
| 1069 | + $suggest = self::suggest_index_name($name, $api_key); | |
| 1070 | + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name'])); | |
| 1071 | + } | |
| 853 | 1072 | if ($res['error'] !== '' || !is_array($res['data'])) { |
| 854 | 1073 | return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty create response', array('code' => $res['code'])); |
| 855 | 1074 | } |
| 856 | 1075 | return $res['data']; |
| @@ -937,8 +1156,72 @@ | ||
| 937 | 1156 | public static function migration_clear() { |
| 938 | 1157 | delete_transient(self::MIGRATION_KEY); |
| 939 | 1158 | } |
| 940 | 1159 | |
| 1160 | + /** | |
| 1161 | + * 3e83e4: is there an unfinished copy INTO this host? The settings save | |
| 1162 | + * and the autosave refuse to switch the live chatbot onto a document index | |
| 1163 | + * while its copy is still running (unless the owner ticks Switch anyway), | |
| 1164 | + * so a production bot never answers from a half-filled index. | |
| 1165 | + * | |
| 1166 | + * @return array|null the running state, or null when nothing blocks | |
| 1167 | + */ | |
| 1168 | + public static function migration_blocks_switch($host) { | |
| 1169 | + $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/')); | |
| 1170 | + $state = self::migration_get(); | |
| 1171 | + if ($host === '' || empty($state) || ($state['status'] ?? '') !== 'running') { | |
| 1172 | + return null; | |
| 1173 | + } | |
| 1174 | + if (strtolower((string) ($state['target_host'] ?? '')) !== $host) { | |
| 1175 | + return null; | |
| 1176 | + } | |
| 1177 | + return $state; | |
| 1178 | + } | |
| 1179 | + | |
| 1180 | + /** Plain-language line for the switch gate: "The copy into X is still running (481 of 4,503 records)…". */ | |
| 1181 | + public static function switch_blocked_message(array $state) { | |
| 1182 | + return sprintf( | |
| 1183 | + /* translators: 1: document index name, 2: records copied, 3: records in total */ | |
| 1184 | + __('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'), | |
| 1185 | + (string) ($state['target_name'] ?: $state['target_host']), | |
| 1186 | + number_format_i18n((int) ($state['copied'] ?? 0)), | |
| 1187 | + number_format_i18n((int) ($state['total'] ?? 0)) | |
| 1188 | + ); | |
| 1189 | + } | |
| 1190 | + | |
| 1191 | + /** | |
| 1192 | + * Run one Pinecone call up to MIGRATE_RETRIES times, 1 s / 2 s / 4 s apart, | |
| 1193 | + * so a single dropped connection or a 5xx does not end the copy. | |
| 1194 | + * | |
| 1195 | + * @param callable $call returns array|true|WP_Error | |
| 1196 | + * @return array{result:mixed,attempts:int} | |
| 1197 | + */ | |
| 1198 | + private static function with_retry(callable $call) { | |
| 1199 | + $result = null; | |
| 1200 | + $attempts = 0; | |
| 1201 | + for ($attempt = 1; $attempt <= self::MIGRATE_RETRIES; $attempt++) { | |
| 1202 | + $attempts = $attempt; | |
| 1203 | + $result = call_user_func($call); | |
| 1204 | + if (!is_wp_error($result)) { | |
| 1205 | + return array('result' => $result, 'attempts' => $attempts); | |
| 1206 | + } | |
| 1207 | + if ($attempt < self::MIGRATE_RETRIES) { | |
| 1208 | + sleep((int) pow(2, $attempt - 1)); | |
| 1209 | + } | |
| 1210 | + } | |
| 1211 | + return array('result' => $result, 'attempts' => $attempts); | |
| 1212 | + } | |
| 1213 | + | |
| 1214 | + /** Seconds one AJAX step may spend copying pages (bounded by PHP's own limit). */ | |
| 1215 | + private static function step_budget() { | |
| 1216 | + $budget = (float) apply_filters('mxchat_pinecone_migrate_step_seconds', self::MIGRATE_STEP_SECONDS); | |
| 1217 | + $limit = (int) ini_get('max_execution_time'); | |
| 1218 | + if ($limit > 0 && $limit - 8 < $budget) { | |
| 1219 | + $budget = max(3, $limit - 8); | |
| 1220 | + } | |
| 1221 | + return $budget; | |
| 1222 | + } | |
| 1223 | + | |
| 941 | 1224 | /** Classic GET /vectors/list page. @return array{ids:array,next:string}|WP_Error */ |
| 942 | 1225 | public static function classic_list_page($host, $api_key, $namespace, $limit, $token) { |
| 943 | 1226 | $params = array('limit' => max(1, min(100, (int) $limit))); |
| 944 | 1227 | if ((string) $namespace !== '') { |
| @@ -1015,12 +1298,133 @@ | ||
| 1015 | 1298 | } |
| 1016 | 1299 | return $data; |
| 1017 | 1300 | } |
| 1018 | 1301 | |
| 1302 | + /** Display key of a namespace in the per-namespace map — Pinecone's console names the default one __default__. */ | |
| 1303 | + public static function ns_key($namespace) { | |
| 1304 | + return (string) $namespace === '' ? '__default__' : (string) $namespace; | |
| 1305 | + } | |
| 1306 | + | |
| 1307 | + /** Raw namespace for a display key (the default namespace is '' on the wire). */ | |
| 1308 | + public static function ns_raw($key) { | |
| 1309 | + return (string) $key === '__default__' ? '' : (string) $key; | |
| 1310 | + } | |
| 1311 | + | |
| 1312 | + /** Classic describe_index_stats → [raw namespace => record count], the default namespace as ''. */ | |
| 1313 | + private static function namespace_counts(array $stats) { | |
| 1314 | + $counts = array(); | |
| 1315 | + foreach ((array) ($stats['namespaces'] ?? array()) as $ns_name => $ns_info) { | |
| 1316 | + $counts[self::ns_raw($ns_name)] = (int) ($ns_info['vectorCount'] ?? $ns_info['recordCount'] ?? 0); | |
| 1317 | + } | |
| 1318 | + return $counts; | |
| 1319 | + } | |
| 1320 | + | |
| 1019 | 1321 | /** |
| 1322 | + * e50d2e: the order namespaces are copied in when every namespace is | |
| 1323 | + * asked for — the default namespace first, then the rest by name. Only | |
| 1324 | + * namespaces that hold records are queued. | |
| 1325 | + */ | |
| 1326 | + private static function namespace_queue(array $counts) { | |
| 1327 | + $queue = array(); | |
| 1328 | + if (!empty($counts[''])) { | |
| 1329 | + $queue[] = ''; | |
| 1330 | + } | |
| 1331 | + $named = array(); | |
| 1332 | + foreach ($counts as $ns => $count) { | |
| 1333 | + if ($ns !== '' && $count > 0) { | |
| 1334 | + $named[] = (string) $ns; | |
| 1335 | + } | |
| 1336 | + } | |
| 1337 | + sort($named, SORT_STRING); | |
| 1338 | + return array_merge($queue, $named); | |
| 1339 | + } | |
| 1340 | + | |
| 1341 | + private static function new_namespace_entry($namespace, $total, $status = 'queued') { | |
| 1342 | + return array('namespace' => (string) $namespace, 'total' => (int) $total, 'copied' => 0, 'status' => $status, 'target_count' => null); | |
| 1343 | + } | |
| 1344 | + | |
| 1345 | + /** | |
| 1346 | + * e50d2e: the per-namespace map of a migration state — display key → | |
| 1347 | + * namespace / total / copied / status / target_count. A state written | |
| 1348 | + * before this build copied one namespace and carries no map, so one is | |
| 1349 | + * synthesised from its single namespace; the Delete guard and the step | |
| 1350 | + * loop read every state through here. | |
| 1351 | + */ | |
| 1352 | + public static function per_namespace_map(array $state) { | |
| 1353 | + if (!empty($state['per_namespace']) && is_array($state['per_namespace'])) { | |
| 1354 | + return $state['per_namespace']; | |
| 1355 | + } | |
| 1356 | + $namespace = (string) ($state['namespace'] ?? ''); | |
| 1357 | + $status = ($state['status'] ?? '') === 'running' ? 'running' : (in_array($state['status'] ?? '', array('done', 'source_deleted'), true) ? 'done' : 'queued'); | |
| 1358 | + $entry = self::new_namespace_entry($namespace, (int) ($state['total'] ?? 0), $status); | |
| 1359 | + $entry['copied'] = (int) ($state['copied'] ?? 0); | |
| 1360 | + $entry['target_count'] = isset($state['target_count']) ? $state['target_count'] : null; | |
| 1361 | + return array(self::ns_key($namespace) => $entry); | |
| 1362 | + } | |
| 1363 | + | |
| 1364 | + /** | |
| 1365 | + * e50d2e: widen a single-namespace copy — running or finished — to every | |
| 1366 | + * namespace of the source that holds records. Namespaces already copied | |
| 1367 | + * keep their entry, the rest queue behind the current one, the total | |
| 1368 | + * becomes the sum, and "other namespaces" empties because none are left | |
| 1369 | + * out any more. A finished copy with nothing left to queue stays done. | |
| 1370 | + * | |
| 1371 | + * @return array|WP_Error state | |
| 1372 | + */ | |
| 1373 | + private static function extend_to_all_namespaces(array $state, $api_key) { | |
| 1374 | + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key); | |
| 1375 | + if (is_wp_error($stats)) { | |
| 1376 | + return $stats; | |
| 1377 | + } | |
| 1378 | + $ns_counts = self::namespace_counts($stats); | |
| 1379 | + $map = self::per_namespace_map($state); | |
| 1380 | + $queue = array_values((array) ($state['queue'] ?? array())); | |
| 1381 | + $current = isset($state['current']) ? (string) $state['current'] : (string) ($state['namespace'] ?? ''); | |
| 1382 | + foreach (self::namespace_queue($ns_counts) as $ns) { | |
| 1383 | + if (isset($map[self::ns_key($ns)]) || $ns === $current || in_array($ns, $queue, true)) { | |
| 1384 | + continue; | |
| 1385 | + } | |
| 1386 | + $map[self::ns_key($ns)] = self::new_namespace_entry($ns, $ns_counts[$ns]); | |
| 1387 | + $queue[] = $ns; | |
| 1388 | + } | |
| 1389 | + $total = 0; | |
| 1390 | + foreach ($map as $entry) { | |
| 1391 | + $total += (int) ($entry['total'] ?? 0); | |
| 1392 | + } | |
| 1393 | + $state['per_namespace'] = $map; | |
| 1394 | + $state['queue'] = $queue; | |
| 1395 | + $state['current'] = $current; | |
| 1396 | + $state['namespace'] = $current; | |
| 1397 | + $state['total'] = $total; | |
| 1398 | + $state['total_all'] = (int) ($stats['totalVectorCount'] ?? $total); | |
| 1399 | + $state['other_namespaces'] = array(); | |
| 1400 | + $state['all_namespaces'] = true; | |
| 1401 | + if (($state['status'] ?? '') === 'done' && !empty($queue)) { | |
| 1402 | + $next = array_shift($queue); | |
| 1403 | + $state['queue'] = $queue; | |
| 1404 | + $state['current'] = $next; | |
| 1405 | + $state['namespace'] = $next; | |
| 1406 | + $state['token'] = ''; | |
| 1407 | + $state['status'] = 'running'; | |
| 1408 | + $state['error'] = ''; | |
| 1409 | + $state['error_at'] = 0; | |
| 1410 | + $state['per_namespace'][self::ns_key($next)]['status'] = 'running'; | |
| 1411 | + unset($state['finished']); | |
| 1412 | + } | |
| 1413 | + return $state; | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + /** | |
| 1020 | 1417 | * Validate and start (or resume) a migration. @return array|WP_Error state |
| 1418 | + * | |
| 1419 | + * e50d2e: $all_namespaces copies every namespace of the source that holds | |
| 1420 | + * records — the default namespace first, then the rest by name — each into | |
| 1421 | + * the same-named namespace of the document index, one after another, in | |
| 1422 | + * one run. Without it the copy is exactly 3e83e4's: the one configured | |
| 1423 | + * namespace. Asking for every namespace on a copy that is already running | |
| 1424 | + * or finished keeps what was copied and queues the rest. | |
| 1021 | 1425 | */ |
| 1022 | - public static function migrate_start($source_host, array $target_cfg, $restart = false) { | |
| 1426 | + public static function migrate_start($source_host, array $target_cfg, $restart = false, $all_namespaces = false) { | |
| 1023 | 1427 | $source_host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $source_host), '/')); |
| 1024 | 1428 | if ($source_host === '') { |
| 1025 | 1429 | return new WP_Error('migrate', 'Enter the host of the vector index to copy from.'); |
| 1026 | 1430 | } |
| @@ -1029,21 +1433,77 @@ | ||
| 1029 | 1433 | } |
| 1030 | 1434 | if ($source_host === strtolower($target_cfg['host'])) { |
| 1031 | 1435 | return new WP_Error('migrate', 'The source and the document index are the same host.'); |
| 1032 | 1436 | } |
| 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 | |
| 1437 | + $existing = self::migration_get(); | |
| 1438 | + $same_copy = !empty($existing) && ($existing['source_host'] ?? '') === $source_host && strtolower((string) ($existing['target_host'] ?? '')) === strtolower($target_cfg['host']); | |
| 1439 | + if (!$restart && $same_copy && ($existing['status'] ?? '') === 'running') { | |
| 1440 | + // Resume from the recorded cursor — whether the last step ended | |
| 1441 | + // cleanly or with an error (3e83e4: a second click continues, | |
| 1442 | + // it never starts over unless asked). | |
| 1443 | + $existing['error'] = ''; | |
| 1444 | + if ($all_namespaces && empty($existing['all_namespaces'])) { | |
| 1445 | + $existing = self::extend_to_all_namespaces($existing, $target_cfg['api_key']); | |
| 1446 | + if (is_wp_error($existing)) { | |
| 1447 | + return $existing; | |
| 1448 | + } | |
| 1449 | + } | |
| 1450 | + return self::migration_set($existing); | |
| 1036 | 1451 | } |
| 1452 | + if (!$restart && $same_copy && $all_namespaces && ($existing['status'] ?? '') === 'done') { | |
| 1453 | + $extended = self::extend_to_all_namespaces($existing, $target_cfg['api_key']); | |
| 1454 | + if (is_wp_error($extended)) { | |
| 1455 | + return $extended; | |
| 1456 | + } | |
| 1457 | + return self::migration_set($extended); | |
| 1458 | + } | |
| 1037 | 1459 | $stats = self::classic_stats($source_host, $target_cfg['api_key']); |
| 1038 | 1460 | if (is_wp_error($stats)) { |
| 1039 | 1461 | return $stats; |
| 1040 | 1462 | } |
| 1041 | 1463 | $namespace = (string) ($target_cfg['namespace'] ?? ''); |
| 1042 | - if ($namespace !== '') { | |
| 1043 | - $total = isset($stats['namespaces'][$namespace]['vectorCount']) ? (int) $stats['namespaces'][$namespace]['vectorCount'] : 0; | |
| 1464 | + // 3e83e4: the total is the SOURCE NAMESPACE's count, never the whole | |
| 1465 | + // index — the copy lists one namespace, so an index with records in | |
| 1466 | + // other namespaces used to report "done" with copied < total and no | |
| 1467 | + // explanation. The other namespaces are recorded for the UI instead. | |
| 1468 | + $ns_counts = self::namespace_counts($stats); | |
| 1469 | + $queue = array(); | |
| 1470 | + $per_namespace = array(); | |
| 1471 | + $other_namespaces = array(); | |
| 1472 | + if ($all_namespaces) { | |
| 1473 | + $queue = self::namespace_queue($ns_counts); | |
| 1474 | + if (empty($queue)) { | |
| 1475 | + $queue = array($namespace); // stats listed no namespaces — copy the configured one, as before | |
| 1476 | + } | |
| 1477 | + $total = 0; | |
| 1478 | + foreach ($queue as $ns) { | |
| 1479 | + if (isset($ns_counts[$ns])) { | |
| 1480 | + $count = (int) $ns_counts[$ns]; | |
| 1481 | + } elseif ($ns === '' && empty($ns_counts)) { | |
| 1482 | + $count = (int) ($stats['totalVectorCount'] ?? 0); | |
| 1483 | + } else { | |
| 1484 | + $count = 0; | |
| 1485 | + } | |
| 1486 | + $per_namespace[self::ns_key($ns)] = self::new_namespace_entry($ns, $count); | |
| 1487 | + $total += $count; | |
| 1488 | + } | |
| 1489 | + $current = (string) array_shift($queue); | |
| 1490 | + $per_namespace[self::ns_key($current)]['status'] = 'running'; | |
| 1044 | 1491 | } else { |
| 1045 | - $total = (int) ($stats['totalVectorCount'] ?? 0); | |
| 1492 | + $current = $namespace; | |
| 1493 | + if (isset($ns_counts[$namespace])) { | |
| 1494 | + $total = (int) $ns_counts[$namespace]; | |
| 1495 | + } elseif ($namespace === '' && empty($ns_counts)) { | |
| 1496 | + $total = (int) ($stats['totalVectorCount'] ?? 0); | |
| 1497 | + } else { | |
| 1498 | + $total = 0; | |
| 1499 | + } | |
| 1500 | + $per_namespace[self::ns_key($namespace)] = self::new_namespace_entry($namespace, $total, 'running'); | |
| 1501 | + foreach ($ns_counts as $ns_name => $ns_count) { | |
| 1502 | + if ($ns_name !== $namespace && $ns_count > 0) { | |
| 1503 | + $other_namespaces[self::ns_key($ns_name)] = $ns_count; | |
| 1504 | + } | |
| 1505 | + } | |
| 1046 | 1506 | } |
| 1047 | 1507 | $source_dimension = (int) ($stats['dimension'] ?? 0); |
| 1048 | 1508 | $target_index = self::find_index_by_host($target_cfg['host'], $target_cfg['api_key']); |
| 1049 | 1509 | if (is_wp_error($target_index)) { |
| @@ -1056,20 +1516,33 @@ | ||
| 1056 | 1516 | if ($source_dimension > 0 && $schema['dimension'] > 0 && $source_dimension !== (int) $schema['dimension']) { |
| 1057 | 1517 | 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 | 1518 | } |
| 1059 | 1519 | $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' => '', | |
| 1520 | + 'status' => 'running', | |
| 1521 | + 'source_host' => $source_host, | |
| 1522 | + 'target_host' => $target_cfg['host'], | |
| 1523 | + 'target_name' => (string) ($target_index['name'] ?? ''), | |
| 1524 | + 'namespace' => $current, | |
| 1525 | + 'total' => $total, | |
| 1526 | + 'total_all' => (int) ($stats['totalVectorCount'] ?? $total), | |
| 1527 | + 'other_namespaces' => $other_namespaces, | |
| 1528 | + // e50d2e: the namespace being copied, the ones still queued and | |
| 1529 | + // a per-namespace ledger — the Delete guard checks every entry. | |
| 1530 | + 'all_namespaces' => (bool) $all_namespaces, | |
| 1531 | + 'current' => $current, | |
| 1532 | + 'queue' => $queue, | |
| 1533 | + 'per_namespace' => $per_namespace, | |
| 1534 | + 'copied' => 0, | |
| 1535 | + 'skipped' => 0, | |
| 1536 | + 'token' => '', | |
| 1537 | + 'batches' => 0, | |
| 1538 | + 'retries' => 0, | |
| 1539 | + 'failed_steps' => 0, | |
| 1540 | + 'started' => time(), | |
| 1541 | + 'error' => '', | |
| 1542 | + 'error_at' => 0, | |
| 1543 | + 'target_count' => null, | |
| 1544 | + 'source_name' => '', | |
| 1072 | 1545 | ); |
| 1073 | 1546 | $source_index = self::find_index_by_host($source_host, $target_cfg['api_key']); |
| 1074 | 1547 | if (!is_wp_error($source_index)) { |
| 1075 | 1548 | $state['source_name'] = (string) ($source_index['name'] ?? ''); |
| @@ -1077,10 +1550,15 @@ | ||
| 1077 | 1550 | return self::migration_set($state); |
| 1078 | 1551 | } |
| 1079 | 1552 | |
| 1080 | 1553 | /** |
| 1081 | - * Copy one page: list → fetch (values + metadata) → documents/upsert. | |
| 1082 | - * Idempotent — the same ids land in the same place on a re-run. | |
| 1554 | + * Copy pages until the step's time budget is used or the source is | |
| 1555 | + * exhausted: list → fetch (values + metadata) → documents/upsert, each | |
| 1556 | + * call retried with backoff. Idempotent — the same ids land in the same | |
| 1557 | + * place on a re-run. The cursor is persisted after EVERY page, so a step | |
| 1558 | + * that dies mid-way (proxy timeout, killed request) loses at most one | |
| 1559 | + * page of work and the next click continues from the recorded position. | |
| 1560 | + * | |
| 1083 | 1561 | * @return array|WP_Error state |
| 1084 | 1562 | */ |
| 1085 | 1563 | public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) { |
| 1086 | 1564 | if (($state['status'] ?? '') !== 'running') { |
| @@ -1085,44 +1563,205 @@ | ||
| 1085 | 1563 | public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) { |
| 1086 | 1564 | if (($state['status'] ?? '') !== 'running') { |
| 1087 | 1565 | return $state; |
| 1088 | 1566 | } |
| 1089 | - if (($state['target_host'] ?? '') !== ($target_cfg['host'] ?? '')) { | |
| 1567 | + if (strtolower((string) ($state['target_host'] ?? '')) !== strtolower((string) ($target_cfg['host'] ?? ''))) { | |
| 1090 | 1568 | return new WP_Error('migrate', 'The document index host changed since the migration started. Start it again.'); |
| 1091 | 1569 | } |
| 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); | |
| 1570 | + $started = microtime(true); | |
| 1571 | + $budget = self::step_budget(); | |
| 1572 | + $pages = 0; | |
| 1573 | + $api_key = $target_cfg['api_key']; | |
| 1574 | + foreach (array('retries', 'skipped', 'failed_steps', 'error_at') as $counter) { | |
| 1575 | + $state[$counter] = (int) ($state[$counter] ?? 0); // states written before 3e83e4 lack these | |
| 1096 | 1576 | } |
| 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); | |
| 1577 | + if (empty($state['per_namespace']) || !is_array($state['per_namespace'])) { | |
| 1578 | + // e50d2e: a state written before this build copied one namespace and has no ledger | |
| 1579 | + $state['per_namespace'] = self::per_namespace_map($state); | |
| 1580 | + $state['current'] = (string) ($state['namespace'] ?? ''); | |
| 1581 | + $state['queue'] = array(); | |
| 1582 | + } | |
| 1583 | + if (!isset($state['current'])) { | |
| 1584 | + $state['current'] = (string) ($state['namespace'] ?? ''); | |
| 1585 | + } | |
| 1586 | + do { | |
| 1587 | + $source_host = $state['source_host']; | |
| 1588 | + $namespace = (string) $state['current']; | |
| 1589 | + $ns_key = self::ns_key($namespace); | |
| 1590 | + $token = $state['token']; | |
| 1591 | + // e50d2e: the same namespace on the document side — every bot keeps reading its own | |
| 1592 | + $page_cfg = $target_cfg; | |
| 1593 | + $page_cfg['namespace'] = $namespace; | |
| 1594 | + $listed = self::with_retry(function () use ($source_host, $api_key, $namespace, $batch, $token) { | |
| 1595 | + return self::classic_list_page($source_host, $api_key, $namespace, $batch, $token); | |
| 1596 | + }); | |
| 1597 | + $state['retries'] += max(0, $listed['attempts'] - 1); | |
| 1598 | + $page = $listed['result']; | |
| 1599 | + if (is_wp_error($page)) { | |
| 1600 | + return self::migration_fail($state, 'Listing the source index failed after ' . $listed['attempts'] . ' attempts: ' . $page->get_error_message()); | |
| 1102 | 1601 | } |
| 1103 | - $documents = array(); | |
| 1104 | - foreach ($vectors as $id => $vector) { | |
| 1105 | - if (empty($vector['values']) || !is_array($vector['values'])) { | |
| 1106 | - continue; | |
| 1602 | + $ids = $page['ids']; | |
| 1603 | + if (!empty($ids)) { | |
| 1604 | + $fetched = self::with_retry(function () use ($source_host, $api_key, $namespace, $ids) { | |
| 1605 | + return self::classic_fetch($source_host, $api_key, $namespace, $ids); | |
| 1606 | + }); | |
| 1607 | + $state['retries'] += max(0, $fetched['attempts'] - 1); | |
| 1608 | + $vectors = $fetched['result']; | |
| 1609 | + if (is_wp_error($vectors)) { | |
| 1610 | + return self::migration_fail($state, 'Reading ' . count($ids) . ' records from the source index failed after ' . $fetched['attempts'] . ' attempts: ' . $vectors->get_error_message()); | |
| 1107 | 1611 | } |
| 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); | |
| 1612 | + $documents = array(); | |
| 1613 | + $skipped = 0; | |
| 1614 | + foreach ($vectors as $id => $vector) { | |
| 1615 | + if (empty($vector['values']) || !is_array($vector['values'])) { | |
| 1616 | + $skipped++; | |
| 1617 | + continue; | |
| 1618 | + } | |
| 1619 | + $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array(); | |
| 1620 | + $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata); | |
| 1621 | + } | |
| 1622 | + $skipped += max(0, count($ids) - count($vectors)); | |
| 1623 | + if (!empty($documents)) { | |
| 1624 | + $upserted = self::with_retry(function () use ($documents, $page_cfg) { | |
| 1625 | + return self::upsert($documents, $page_cfg); | |
| 1626 | + }); | |
| 1627 | + $state['retries'] += max(0, $upserted['attempts'] - 1); | |
| 1628 | + if (is_wp_error($upserted['result'])) { | |
| 1629 | + return self::migration_fail($state, 'Writing ' . count($documents) . ' records to the document index failed after ' . $upserted['attempts'] . ' attempts: ' . $upserted['result']->get_error_message()); | |
| 1630 | + } | |
| 1631 | + } | |
| 1632 | + $state['copied'] = (int) $state['copied'] + count($documents); | |
| 1633 | + $state['skipped'] = (int) ($state['skipped'] ?? 0) + $skipped; | |
| 1634 | + $state['per_namespace'][$ns_key]['copied'] = (int) ($state['per_namespace'][$ns_key]['copied'] ?? 0) + count($documents); | |
| 1110 | 1635 | } |
| 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); | |
| 1636 | + $state['batches'] = (int) $state['batches'] + 1; | |
| 1637 | + $state['token'] = $page['next']; | |
| 1638 | + $state['error'] = ''; | |
| 1639 | + $state['error_at'] = 0; | |
| 1640 | + $pages++; | |
| 1641 | + if ($page['next'] === '') { | |
| 1642 | + // This namespace is copied: record what the document index | |
| 1643 | + // reports for it, then move on to the next queued namespace | |
| 1644 | + // inside the same time budget (e50d2e), or finish. | |
| 1645 | + $state['per_namespace'][$ns_key]['status'] = 'done'; | |
| 1646 | + $state['per_namespace'][$ns_key]['finished'] = time(); | |
| 1647 | + $ns_target = self::namespace_count($page_cfg); | |
| 1648 | + $state['per_namespace'][$ns_key]['target_count'] = $ns_target > 0 ? $ns_target : null; | |
| 1649 | + $queue = array_values((array) ($state['queue'] ?? array())); | |
| 1650 | + if (!empty($queue)) { | |
| 1651 | + $next = (string) array_shift($queue); | |
| 1652 | + $state['queue'] = $queue; | |
| 1653 | + $state['current'] = $next; | |
| 1654 | + $state['namespace'] = $next; | |
| 1655 | + $state['token'] = ''; | |
| 1656 | + if (!isset($state['per_namespace'][self::ns_key($next)])) { | |
| 1657 | + $state['per_namespace'][self::ns_key($next)] = self::new_namespace_entry($next, 0); | |
| 1658 | + } | |
| 1659 | + $state['per_namespace'][self::ns_key($next)]['status'] = 'running'; | |
| 1660 | + self::migration_set($state); | |
| 1661 | + continue; // re-checks the budget, then lists the next namespace from its first page | |
| 1662 | + } | |
| 1663 | + $state['status'] = 'done'; | |
| 1664 | + $state['finished'] = time(); | |
| 1665 | + // The finished state names what both sides report, so the UI | |
| 1666 | + // can say "5,000 copied; the document index holds 5,000". | |
| 1667 | + $target_count = 0; | |
| 1668 | + foreach ($state['per_namespace'] as $entry) { | |
| 1669 | + $target_count += (int) ($entry['target_count'] ?? 0); | |
| 1670 | + } | |
| 1671 | + $state['target_count'] = $target_count > 0 ? $target_count : null; | |
| 1672 | + $state['target_count_at'] = time(); | |
| 1673 | + break; | |
| 1115 | 1674 | } |
| 1116 | - $state['copied'] = (int) $state['copied'] + count($documents); | |
| 1675 | + self::migration_set($state); // cursor persisted per page, not per step | |
| 1676 | + } while ((microtime(true) - $started) < $budget); | |
| 1677 | + $state['last_step_pages'] = $pages; | |
| 1678 | + $state['last_step_seconds'] = round(microtime(true) - $started, 2); | |
| 1679 | + return self::migration_set($state); | |
| 1680 | + } | |
| 1681 | + | |
| 1682 | + /** Record count of ONE namespace on a document index (0 when not listed) — count() reads the whole index for the default namespace. */ | |
| 1683 | + private static function namespace_count(array $cfg) { | |
| 1684 | + $stats = self::stats($cfg); | |
| 1685 | + if (is_wp_error($stats)) { | |
| 1686 | + return 0; | |
| 1117 | 1687 | } |
| 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(); | |
| 1688 | + $ns = (string) ($cfg['namespace'] ?? ''); | |
| 1689 | + return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0; | |
| 1690 | + } | |
| 1691 | + | |
| 1692 | + /** | |
| 1693 | + * e50d2e: namespaces of the OLD index that still hold records without a | |
| 1694 | + * finished, count-matching copy — read from the live source stats, so a | |
| 1695 | + * namespace filled after the copy counts too. Empty array = every record | |
| 1696 | + * on the old index has a copy; the Delete old index button refuses on | |
| 1697 | + * anything else and names what is missing. | |
| 1698 | + * | |
| 1699 | + * @return array|WP_Error display key → ['namespace','source','copied','status'] | |
| 1700 | + */ | |
| 1701 | + public static function uncopied_namespaces(array $state, $api_key) { | |
| 1702 | + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key); | |
| 1703 | + if (is_wp_error($stats)) { | |
| 1704 | + return $stats; | |
| 1124 | 1705 | } |
| 1706 | + $counts = self::namespace_counts($stats); | |
| 1707 | + if (empty($counts) && (int) ($stats['totalVectorCount'] ?? 0) > 0) { | |
| 1708 | + $counts[''] = (int) $stats['totalVectorCount']; | |
| 1709 | + } | |
| 1710 | + $map = self::per_namespace_map($state); | |
| 1711 | + $missing = array(); | |
| 1712 | + foreach ($counts as $ns => $count) { | |
| 1713 | + if ($count <= 0) { | |
| 1714 | + continue; | |
| 1715 | + } | |
| 1716 | + $key = self::ns_key($ns); | |
| 1717 | + $entry = isset($map[$key]) && is_array($map[$key]) ? $map[$key] : null; | |
| 1718 | + if ($entry === null || ($entry['status'] ?? '') !== 'done' || (int) ($entry['copied'] ?? 0) < $count) { | |
| 1719 | + $missing[$key] = array( | |
| 1720 | + 'namespace' => (string) $ns, | |
| 1721 | + 'source' => (int) $count, | |
| 1722 | + 'copied' => $entry ? (int) ($entry['copied'] ?? 0) : 0, | |
| 1723 | + 'status' => $entry ? (string) ($entry['status'] ?? '') : 'not copied', | |
| 1724 | + ); | |
| 1725 | + } | |
| 1726 | + } | |
| 1727 | + // Named in the copy order (default first, then by name) — Pinecone's stats object order varies call to call. | |
| 1728 | + $ordered = array(); | |
| 1729 | + foreach (self::namespace_queue(array_combine(array_map(array(__CLASS__, 'ns_raw'), array_keys($missing)), array_column($missing, 'source')) ?: array()) as $ns) { | |
| 1730 | + $ordered[self::ns_key($ns)] = $missing[self::ns_key($ns)]; | |
| 1731 | + } | |
| 1732 | + return $ordered + $missing; | |
| 1733 | + } | |
| 1734 | + | |
| 1735 | + /** Plain-language refusal naming every namespace the copy is missing. (Plain __(): the card's JS text-escapes it.) */ | |
| 1736 | + public static function uncopied_message(array $missing) { | |
| 1737 | + $parts = array(); | |
| 1738 | + foreach ($missing as $key => $m) { | |
| 1739 | + $parts[] = sprintf( | |
| 1740 | + /* translators: 1: namespace name, 2: records copied, 3: records on the old index */ | |
| 1741 | + __('%1$s (%2$s of %3$s records copied)', 'mxchat'), | |
| 1742 | + $key, | |
| 1743 | + number_format_i18n((int) $m['copied']), | |
| 1744 | + number_format_i18n((int) $m['source']) | |
| 1745 | + ); | |
| 1746 | + } | |
| 1747 | + return sprintf( | |
| 1748 | + /* translators: %s: comma-separated namespace names with their counts */ | |
| 1749 | + _n( | |
| 1750 | + '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.', | |
| 1751 | + '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.', | |
| 1752 | + count($missing), | |
| 1753 | + 'mxchat' | |
| 1754 | + ), | |
| 1755 | + implode(', ', $parts) | |
| 1756 | + ); | |
| 1757 | + } | |
| 1758 | + | |
| 1759 | + /** Record a step failure without ending the copy: status stays running, the cursor stays put. */ | |
| 1760 | + private static function migration_fail(array $state, $message) { | |
| 1761 | + $state['error'] = (string) $message; | |
| 1762 | + $state['error_at'] = time(); | |
| 1763 | + $state['failed_steps'] = (int) ($state['failed_steps'] ?? 0) + 1; | |
| 1125 | 1764 | return self::migration_set($state); |
| 1126 | 1765 | } |
| 1127 | 1766 | |
| 1128 | 1767 | // ===================================================================== |
| @@ -1131,12 +1770,47 @@ | ||
| 1131 | 1770 | |
| 1132 | 1771 | public static function boot() { |
| 1133 | 1772 | add_action('wp_ajax_mxchat_pinecone_docs_create_index', array(__CLASS__, 'ajax_create_index')); |
| 1134 | 1773 | add_action('wp_ajax_mxchat_pinecone_docs_check_index', array(__CLASS__, 'ajax_check_index')); |
| 1774 | + add_action('wp_ajax_mxchat_pinecone_docs_suggest_name', array(__CLASS__, 'ajax_suggest_name')); | |
| 1135 | 1775 | add_action('wp_ajax_mxchat_pinecone_docs_migrate', array(__CLASS__, 'ajax_migrate')); |
| 1136 | 1776 | add_action('wp_ajax_mxchat_pinecone_docs_delete_old_index', array(__CLASS__, 'ajax_delete_old_index')); |
| 1137 | 1777 | } |
| 1138 | 1778 | |
| 1779 | + /** | |
| 1780 | + * Suggest a free document-index name for the name in the field (3e83e4). | |
| 1781 | + * With a host that already answers in the project, the name is that | |
| 1782 | + * index's own — the owner is pointing at an existing document index, not | |
| 1783 | + * about to create one. | |
| 1784 | + */ | |
| 1785 | + public static function ajax_suggest_name() { | |
| 1786 | + self::ajax_guard(); | |
| 1787 | + $api_key = self::posted_api_key(); | |
| 1788 | + $base = isset($_POST['base']) ? sanitize_text_field(wp_unslash($_POST['base'])) : ''; | |
| 1789 | + $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : ''; | |
| 1790 | + if ($api_key === '') { | |
| 1791 | + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat'))); | |
| 1792 | + } | |
| 1793 | + if ($host !== '') { | |
| 1794 | + $index = self::find_index_by_host($host, $api_key); | |
| 1795 | + if (!is_wp_error($index) && !empty($index['name'])) { | |
| 1796 | + wp_send_json_success(array('name' => strtolower((string) $index['name']), 'resolved' => true, 'taken' => false, 'base' => strtolower($base))); | |
| 1797 | + } | |
| 1798 | + } | |
| 1799 | + $suggest = self::suggest_index_name($base, $api_key); | |
| 1800 | + wp_send_json_success(array('name' => $suggest['name'], 'resolved' => false, 'taken' => $suggest['taken'], 'base' => strtolower($base))); | |
| 1801 | + } | |
| 1802 | + | |
| 1803 | + /** The document-index target for a migration call: the host in the form, else the saved one. */ | |
| 1804 | + private static function posted_target_host(array $options) { | |
| 1805 | + $posted = isset($_POST['target_host']) ? sanitize_text_field(wp_unslash($_POST['target_host'])) : ''; | |
| 1806 | + $posted = strtolower(trim(str_replace(array('https://', 'http://'), '', $posted), '/')); | |
| 1807 | + if ($posted !== '') { | |
| 1808 | + return $posted; | |
| 1809 | + } | |
| 1810 | + return strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/')); | |
| 1811 | + } | |
| 1812 | + | |
| 1139 | 1813 | private static function ajax_guard() { |
| 1140 | 1814 | check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce'); |
| 1141 | 1815 | if (!current_user_can('manage_options')) { |
| 1142 | 1816 | wp_send_json_error(array('message' => esc_html__('Permission denied.', 'mxchat')), 403); |
| @@ -1171,9 +1845,14 @@ | ||
| 1171 | 1845 | } |
| 1172 | 1846 | $dimension = self::expected_dimension(); |
| 1173 | 1847 | $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key); |
| 1174 | 1848 | if (is_wp_error($index)) { |
| 1175 | - wp_send_json_error(array('message' => $index->get_error_message())); | |
| 1849 | + $data = $index->get_error_data(); | |
| 1850 | + wp_send_json_error(array( | |
| 1851 | + 'message' => $index->get_error_message(), | |
| 1852 | + 'code' => $index->get_error_code(), | |
| 1853 | + 'suggested' => is_array($data) ? (string) ($data['suggested'] ?? '') : '', | |
| 1854 | + )); | |
| 1176 | 1855 | } |
| 1177 | 1856 | $host = (string) ($index['host'] ?? ''); |
| 1178 | 1857 | // Give Pinecone a few seconds — small serverless indexes are usually Ready quickly. |
| 1179 | 1858 | $ready = !empty($index['status']['ready']); |
| @@ -1186,21 +1865,24 @@ | ||
| 1186 | 1865 | } |
| 1187 | 1866 | } |
| 1188 | 1867 | $options = get_option(self::OPTION, array()); |
| 1189 | 1868 | $options = is_array($options) ? $options : array(); |
| 1869 | + // 3e83e4: creating the index no longer moves the live chatbot. The | |
| 1870 | + // host and index type are only written when the owner saves the | |
| 1871 | + // form; here we record that this host passed the check (the save | |
| 1872 | + // gate) and the settings the index was created with. | |
| 1190 | 1873 | $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 | 1874 | 'mxchat_pinecone_docs_verified_host' => $host, |
| 1196 | 1875 | 'mxchat_pinecone_docs_cloud' => array_key_exists($cloud, self::clouds()) ? $cloud : 'aws', |
| 1197 | 1876 | 'mxchat_pinecone_docs_region' => $region ?: 'us-east-1', |
| 1198 | 1877 | 'mxchat_pinecone_docs_language' => array_key_exists($language, self::languages()) ? $language : 'en', |
| 1199 | 1878 | ); |
| 1879 | + if (empty($options['mxchat_pinecone_api_key'])) { | |
| 1880 | + $changes['mxchat_pinecone_api_key'] = $api_key; | |
| 1881 | + } | |
| 1200 | 1882 | // Remember the classic index so Migrate can copy from it. |
| 1201 | 1883 | $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'])) { | |
| 1884 | + if ($previous_host !== '' && strtolower($previous_host) !== strtolower($host) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) { | |
| 1203 | 1885 | $changes['mxchat_pinecone_vector_host'] = $previous_host; |
| 1204 | 1886 | $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? ''); |
| 1205 | 1887 | } |
| 1206 | 1888 | self::persist_options($changes); |
| @@ -1209,11 +1891,12 @@ | ||
| 1209 | 1891 | 'name' => strtolower($name), |
| 1210 | 1892 | 'dimension' => $dimension, |
| 1211 | 1893 | 'ready' => $ready, |
| 1212 | 1894 | 'vector_host' => $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''), |
| 1895 | + // Plain text: the card's JS escapes it on render (esc_html__ here double-escaped the quotes to a literal "). | |
| 1213 | 1896 | '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), | |
| 1897 | + ? 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) | |
| 1898 | + : 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 | 1899 | )); |
| 1217 | 1900 | } |
| 1218 | 1901 | |
| 1219 | 1902 | /** Check index: assert the host is a document index MxChat can use. */ |
| @@ -1244,15 +1927,23 @@ | ||
| 1244 | 1927 | $check['vector_host'] = $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''); |
| 1245 | 1928 | wp_send_json_success($check); |
| 1246 | 1929 | } |
| 1247 | 1930 | |
| 1248 | - /** Migrate: mode start | step | status | reset. Each step copies one page. */ | |
| 1931 | + /** | |
| 1932 | + * Migrate: mode start | step | status | reset. | |
| 1933 | + * | |
| 1934 | + * 3e83e4: the copy is DECOUPLED from the live retrieval path. The target | |
| 1935 | + * is the document-index host in the form (posted as target_host) plus the | |
| 1936 | + * API key, checked against Pinecone at start — the saved index type is | |
| 1937 | + * never consulted, so the site keeps answering from its vector index for | |
| 1938 | + * the whole copy and the owner switches by saving the settings afterwards. | |
| 1939 | + * Each step copies pages for up to MIGRATE_STEP_SECONDS. | |
| 1940 | + */ | |
| 1249 | 1941 | public static function ajax_migrate() { |
| 1250 | 1942 | self::ajax_guard(); |
| 1251 | 1943 | $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'status'; |
| 1252 | 1944 | $options = get_option(self::OPTION, array()); |
| 1253 | 1945 | $options = is_array($options) ? $options : array(); |
| 1254 | - $target = self::cfg_from_options($options); | |
| 1255 | 1946 | if ($mode === 'reset') { |
| 1256 | 1947 | self::migration_clear(); |
| 1257 | 1948 | wp_send_json_success(array('state' => array())); |
| 1258 | 1949 | } |
| @@ -1258,25 +1949,37 @@ | ||
| 1258 | 1949 | } |
| 1259 | 1950 | if ($mode === 'status') { |
| 1260 | 1951 | wp_send_json_success(array('state' => self::migration_get())); |
| 1261 | 1952 | } |
| 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'))); | |
| 1953 | + $api_key = self::posted_api_key(); | |
| 1954 | + $namespace = (string) ($options['mxchat_pinecone_namespace'] ?? ''); | |
| 1955 | + if ($api_key === '') { | |
| 1956 | + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat'))); | |
| 1264 | 1957 | } |
| 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 | 1958 | if ($mode === 'start') { |
| 1959 | + $target_host = self::posted_target_host($options); | |
| 1960 | + if ($target_host === '') { | |
| 1961 | + 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'))); | |
| 1962 | + } | |
| 1963 | + $check = self::check_document_index($target_host, $api_key, self::expected_dimension()); | |
| 1964 | + if (!$check['ok']) { | |
| 1965 | + wp_send_json_error(array('message' => $check['message'])); | |
| 1966 | + } | |
| 1967 | + $target = array('api_key' => $api_key, 'host' => strtolower($check['host']), 'namespace' => $namespace, 'index_type' => 'document'); | |
| 1269 | 1968 | $source_host = isset($_POST['source_host']) ? sanitize_text_field(wp_unslash($_POST['source_host'])) : ''; |
| 1270 | 1969 | $restart = !empty($_POST['restart']); |
| 1271 | - $state = self::migrate_start($source_host, $target, $restart); | |
| 1970 | + $all_ns = !empty($_POST['all_namespaces']); // e50d2e: Copy every namespace | |
| 1971 | + $state = self::migrate_start($source_host, $target, $restart, $all_ns); | |
| 1272 | 1972 | if (is_wp_error($state)) { |
| 1273 | 1973 | wp_send_json_error(array('message' => $state->get_error_message())); |
| 1274 | 1974 | } |
| 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'])); | |
| 1975 | + $changes = array('mxchat_pinecone_docs_verified_host' => $target['host']); | |
| 1976 | + if ($source_host !== '' && strtolower($state['source_host']) !== strtolower((string) ($options['mxchat_pinecone_vector_host'] ?? ''))) { | |
| 1977 | + $changes['mxchat_pinecone_vector_host'] = $state['source_host']; | |
| 1978 | + $changes['mxchat_pinecone_vector_index'] = $state['source_name']; | |
| 1277 | 1979 | } |
| 1278 | - wp_send_json_success(array('state' => $state)); | |
| 1980 | + self::persist_options($changes); | |
| 1981 | + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options))); | |
| 1279 | 1982 | } |
| 1280 | 1983 | if ($mode === 'step') { |
| 1281 | 1984 | $state = self::migration_get(); |
| 1282 | 1985 | if (empty($state)) { |
| @@ -1281,8 +1984,9 @@ | ||
| 1281 | 1984 | $state = self::migration_get(); |
| 1282 | 1985 | if (empty($state)) { |
| 1283 | 1986 | wp_send_json_error(array('message' => esc_html__('No migration in progress. Click Migrate to start one.', 'mxchat'))); |
| 1284 | 1987 | } |
| 1988 | + $target = array('api_key' => $api_key, 'host' => (string) ($state['target_host'] ?? ''), 'namespace' => (string) ($state['current'] ?? ($state['namespace'] ?? $namespace)), 'index_type' => 'document'); | |
| 1285 | 1989 | $state = self::migrate_step($state, $target); |
| 1286 | 1990 | if (is_wp_error($state)) { |
| 1287 | 1991 | wp_send_json_error(array('message' => $state->get_error_message())); |
| 1288 | 1992 | } |
| @@ -1288,9 +1992,9 @@ | ||
| 1288 | 1992 | } |
| 1289 | 1993 | if (!empty($state['error'])) { |
| 1290 | 1994 | wp_send_json_error(array('message' => $state['error'], 'state' => $state)); |
| 1291 | 1995 | } |
| 1292 | - wp_send_json_success(array('state' => $state)); | |
| 1996 | + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options))); | |
| 1293 | 1997 | } |
| 1294 | 1998 | wp_send_json_error(array('message' => esc_html__('Unknown migration action.', 'mxchat'))); |
| 1295 | 1999 | } |
| 1296 | 2000 | |
| @@ -1312,8 +2016,19 @@ | ||
| 1312 | 2016 | 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 | 2017 | } |
| 1314 | 2018 | if ($target['index_type'] !== 'document' || strtolower($target['host']) !== strtolower((string) $state['target_host'])) { |
| 1315 | 2019 | wp_send_json_error(array('message' => esc_html__('The saved document index no longer matches the migration. The old index stays.', 'mxchat'))); |
| 2020 | + } | |
| 2021 | + // e50d2e: EVERY namespace that holds records on the old index needs a | |
| 2022 | + // finished copy whose count matches — not just the one copied last. | |
| 2023 | + // Before this, a Multi-Bot or legacy-namespace site could delete | |
| 2024 | + // records that were never copied. | |
| 2025 | + $missing = self::uncopied_namespaces($state, $target['api_key']); | |
| 2026 | + if (is_wp_error($missing)) { | |
| 2027 | + 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()))); | |
| 2028 | + } | |
| 2029 | + if (!empty($missing)) { | |
| 2030 | + wp_send_json_error(array('message' => self::uncopied_message($missing), 'uncopied' => $missing)); | |
| 1316 | 2031 | } |
| 1317 | 2032 | $source = self::find_index_by_host($state['source_host'], $target['api_key']); |
| 1318 | 2033 | if (is_wp_error($source)) { |
| 1319 | 2034 | wp_send_json_error(array('message' => $source->get_error_message())); |