PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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 | includes/class-mxchat-utils.php +1209 -67 3.2.17 → 3.2.23 View file →
@@ -5,8 +5,17 @@
5 5
6 6 class MxChat_Utils {
7 7
8 8 /**
9 + * True while a storage routine is re-writing an entry it is about to re-add
10 + * (submit_chunked_content's clean-slate delete). delete_chunks_for_url() skips
11 + * the Vector Store mirror-delete while set — an internal re-store is not an
12 + * entry removal, and mirroring it would delete-then-reupload the entry's file
13 + * on every chunked save (plan 15b5c6).
14 + */
15 +private static $vectorstore_mirror_suspended = false;
16 +
17 +/**
9 18 * Validate a client-supplied session id (plan-mxchat-20260731-d42bec).
10 19 *
11 20 * sanitize_text_field() — which every session_id read site used before this —
12 21 * preserves '/' and '..'. Harmless where the value is only an option or
@@ -38,8 +47,336 @@
38 47 return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $val) ? $val : '';
39 48 }
40 49
41 50 /**
51 + * Sanitize the lead-capture consent-checkbox label (plan b062c4).
52 + *
53 + * The label is owner-supplied and renders inside the widget's email form, so
54 + * this is a security boundary, not a formatting nicety. One explicit
55 + * allowlist, used at BOTH save time (options.php sanitize + autosave AJAX)
56 + * and render time (widget form, admin surfaces) so the two can never drift:
57 + * an anchor — the whole point is "I agree to the <a>Privacy Policy</a>" —
58 + * plus inline emphasis. No block tags, no images, no style attributes.
59 + *
60 + * The stored consent record keeps this exact sanitized string as "the text
61 + * the visitor saw", so it must be deterministic: same input, same output,
62 + * whichever path ran it.
63 + *
64 + * @param mixed $raw Owner-entered label.
65 + * @return string Sanitized label, capped at 1000 chars.
66 + */
67 +public static function sanitize_consent_label($raw) {
68 + if (!is_scalar($raw)) {
69 + return '';
70 + }
71 +
72 + $allowed = array(
73 + 'a' => array(
74 + 'href' => true,
75 + 'title' => true,
76 + 'target' => true,
77 + 'rel' => true,
78 + ),
79 + 'strong' => array(),
80 + 'em' => array(),
81 + 'br' => array(),
82 + );
83 +
84 + $label = wp_kses(trim((string) $raw), $allowed);
85 +
86 + return mb_substr($label, 0, 1000);
87 +}
88 +
89 +/**
90 + * Per-request cache for get_session_history(). Mirrors get_option()'s
91 + * request-scoped caching, which the mxchat_history_ option reads got for
92 + * free before plan 839c4c moved history reads onto the transcripts table.
93 + */
94 +private static $history_cache = array();
95 +
96 +/**
97 + * Session chat history read from the transcripts table, in the exact array
98 + * shape the legacy mxchat_history_<sid> option stored (plan 839c4c). The
99 + * option was a second copy of state the table already held — measured
100 + * byte-identical in role/content/order on 174 of 177 real sessions, with
101 + * the table a superset on the rest — at up to 64 KB per option row. The
102 + * table is now the single store; nothing writes the option any more.
103 + *
104 + * Shape notes, load-bearing for the consumers:
105 + * - id: the transcripts row id (int). Integer ids make the pollers'
106 + * ">" comparisons correct where the old uniqid() strings only worked by
107 + * accident of hex ordering.
108 + * - timestamp: milliseconds, derived from the table's second-resolution GMT
109 + * column (x1000). Consumers comparing against a real-millisecond client
110 + * cutoff MUST floor the cutoff to the second and err inclusive — see the
111 + * persistence-off filters in class-mxchat-integrator.php.
112 + * - agent_name: the row's user_identifier, which the writer sets to the
113 + * same displayed_name value the option carried (agent name when present,
114 + * else email, else identifier).
115 + *
116 + * Public and static so mxchat-woo / mxchat-forms can call the same accessor
117 + * as core, guarded with method_exists against an older mxchat-basic.
118 + *
119 + * @param string $session_id
120 + * @return array[] Chronological entries: id, role, content, timestamp, agent_name.
121 + */
122 +public static function get_session_history($session_id) {
123 + global $wpdb;
124 +
125 + $session_id = self::sanitize_session_id($session_id);
126 + if ($session_id === '') {
127 + return array();
128 + }
129 +
130 + if (array_key_exists($session_id, self::$history_cache)) {
131 + return self::$history_cache[$session_id];
132 + }
133 +
134 + $table = $wpdb->prefix . 'mxchat_chat_transcripts';
135 +
136 + // No SHOW TABLES guard: this is the chat hot path and the table is
137 + // created on activation (with an admin-load safety net). A genuinely
138 + // missing table fails the query and yields the same empty history the
139 + // old option read produced on a fresh session.
140 + $rows = $wpdb->get_results(
141 + $wpdb->prepare(
142 + "SELECT id, role, message, user_identifier, timestamp
143 + FROM `$table` WHERE session_id = %s ORDER BY id ASC",
144 + $session_id
145 + ),
146 + ARRAY_A
147 + );
148 +
149 + $history = array();
150 + if (is_array($rows)) {
151 + foreach ($rows as $row) {
152 + // The column stores GMT (current_time('mysql', 1) at the writer),
153 + // so pin the parse to UTC rather than the site timezone.
154 + $ts = strtotime($row['timestamp'] . ' +0000');
155 + $history[] = array(
156 + 'id' => (int) $row['id'],
157 + 'role' => (string) $row['role'],
158 + 'content' => (string) $row['message'],
159 + 'timestamp' => ($ts ? $ts : 0) * 1000,
160 + 'agent_name' => (string) $row['user_identifier'],
161 + );
162 + }
163 + }
164 +
165 + self::$history_cache[$session_id] = $history;
166 +
167 + return $history;
168 +}
169 +
170 +/**
171 + * Drop the cached history for one session (or all). The writer calls this
172 + * after every insert so a later read in the same request — e.g. the AI
173 + * context build that follows saving the user's message — sees the new row,
174 + * matching the read-your-own-write behavior update_option() gave the old
175 + * option copy.
176 + *
177 + * @param string|null $session_id Null flushes everything (test seam).
178 + */
179 +public static function flush_session_history_cache($session_id = null) {
180 + if ($session_id === null) {
181 + self::$history_cache = array();
182 + return;
183 + }
184 +
185 + unset(self::$history_cache[(string) $session_id]);
186 +}
187 +
188 +/**
189 + * Append one message to a session's transcript on behalf of an add-on
190 + * (plan 4a664e). The Forms add-on's wizard steps travel over the add-on's
191 + * own AJAX action, never through the chat request, so nothing wrote them
192 + * to the transcript: Transcripts, the live-agent handoff card, Download
193 + * Transcript and every transcript email showed "[WIZARD_COMPLETE]" and no
194 + * answers. This is the write half of get_session_history() — same row
195 + * shape as the chat writer (visitor identity, captured name/email and
196 + * originating page from the session store, GMT timestamp), without the
197 + * new-session side effects (ownership, notification email, delayed
198 + * transcript) that only the visitor's first message should trigger.
199 + *
200 + * Public and static so add-ons can call it, guarded with method_exists
201 + * against an older mxchat-basic.
202 + *
203 + * @param string $session_id Chat session id.
204 + * @param string $role user|bot|system|agent.
205 + * @param string $message Stored as-is: bot rows may carry HTML, user rows
206 + * are rendered as text by every reader.
207 + * @return int The new transcripts row id, 0 when nothing was written.
208 + */
209 +public static function save_session_message($session_id, $role, $message) {
210 + global $wpdb;
211 +
212 + $session_id = self::sanitize_session_id($session_id);
213 + $role = is_string($role) ? strtolower(trim($role)) : '';
214 + $message = is_scalar($message) ? trim((string) $message) : '';
215 +
216 + if ($session_id === '' || $message === ''
217 + || !in_array($role, array('user', 'bot', 'system', 'agent'), true)) {
218 + return 0;
219 + }
220 +
221 + $table = $wpdb->prefix . 'mxchat_chat_transcripts';
222 +
223 + $saved_email = MxChat_Session_Store::get($session_id, 'email');
224 + $saved_name = MxChat_Session_Store::get($session_id, 'name');
225 +
226 + $row = array(
227 + 'user_id' => is_user_logged_in() ? get_current_user_id() : 0,
228 + 'user_identifier' => MxChat_User::mxchat_get_user_identifier(),
229 + 'user_email' => $saved_email ?: MxChat_User::mxchat_get_user_email(),
230 + 'user_name' => $saved_name ?: '',
231 + 'session_id' => $session_id,
232 + 'role' => $role,
233 + 'message' => $message,
234 + 'timestamp' => current_time('mysql', 1),
235 + );
236 +
237 + // Same page attribution as the rest of the session; the column pair was
238 + // added by an upgrade routine, so tolerate its absence like the chat
239 + // writer does.
240 + $originating = MxChat_Session_Store::get($session_id, 'originating_page');
241 + if (is_array($originating) && !empty($originating['url'])
242 + && $wpdb->get_var("SHOW COLUMNS FROM `$table` LIKE 'originating_page_url'")) {
243 + $row['originating_page_url'] = $originating['url'];
244 + $row['originating_page_title'] = isset($originating['title']) ? $originating['title'] : '';
245 + }
246 +
247 + $inserted = $wpdb->insert($table, $row);
248 + if ($inserted === false) {
249 + return 0;
250 + }
251 +
252 + $row_id = (int) $wpdb->insert_id;
253 + self::flush_session_history_cache($session_id);
254 +
255 + return $row_id;
256 +}
257 +
258 +/**
259 + * Most recipients the Notification Email field will accept (plan 2f131a).
260 + * A settings field is not a mailing list.
261 + */
262 +const NOTIFICATION_EMAIL_MAX = 5;
263 +
264 +/**
265 + * Parse the Notification Email field into a list of recipients (plan 2f131a).
266 + *
267 + * THE TRAP THIS EXISTS TO CLOSE: sanitize_email() cannot be the validator for
268 + * this field, because its output for the failing input is VALID. WordPress
269 + * strips the separator and the surplus '@' and concatenates the remains:
270 + *
271 + * [email protected], [email protected] -> [email protected]
272 + *
273 + * and is_email() then returns true on that. So every guard in the plugin passed,
274 + * the address was stored, the autosave ticked green, and both the new-session
275 + * notification and the auto-emailed transcript went to a domain that does not
276 + * exist — with no error anywhere. Validating the RAW part BEFORE sanitizing is
277 + * the whole point; reversing those two lines silently restores the bug.
278 + *
279 + * All-or-nothing by design: if any entry is bad the caller must store NOTHING.
280 + * A partial accept — keeping the good addresses and dropping the bad one — is
281 + * the same defect in a new costume, because the owner still believes everyone
282 + * on their list is being notified.
283 + *
284 + * @param mixed $raw Raw field value, exactly as submitted.
285 + * @return array{emails: string[], error: string} Empty emails + empty error
286 + * means the field was empty.
287 + */
288 +public static function parse_notification_emails($raw) {
289 + $out = array('emails' => array(), 'error' => '');
290 +
291 + if (!is_scalar($raw)) {
292 + $out['error'] = __('The notification email could not be read.', 'mxchat');
293 + return $out;
294 + }
295 +
296 + $raw = trim((string) $raw);
297 + if ($raw === '') {
298 + return $out; // genuinely empty — the caller falls back to admin_email
299 + }
300 +
301 + $seen = array();
302 + foreach (preg_split('/[,;]/', $raw) as $part) {
303 + $part = trim($part);
304 + if ($part === '') {
305 + // A trailing or doubled separator carries no address, so skipping it
306 + // cannot silently drop a recipient. This is the ONLY thing tolerated.
307 + continue;
308 + }
309 +
310 + // RAW first. See the note above — order is load-bearing.
311 + $clean = is_email($part) ? sanitize_email($part) : '';
312 + if ($clean === '' || !is_email($clean)) {
313 + return array(
314 + 'emails' => array(),
315 + 'error' => sprintf(
316 + /* translators: %s: the email address the owner typed. */
317 + __('"%s" is not a valid email address, so nothing was saved. Separate multiple addresses with a comma.', 'mxchat'),
318 + esc_html($part)
319 + ),
320 + );
321 + }
322 +
323 + $key = strtolower($clean);
324 + if (isset($seen[$key])) {
325 + continue; // same address twice would simply mail them twice
326 + }
327 + $seen[$key] = true;
328 + $out['emails'][] = $clean;
329 + }
330 +
331 + if (count($out['emails']) > self::NOTIFICATION_EMAIL_MAX) {
332 + return array(
333 + 'emails' => array(),
334 + 'error' => sprintf(
335 + /* translators: %d: maximum number of notification recipients. */
336 + __('Enter at most %d email addresses, separated by commas.', 'mxchat'),
337 + self::NOTIFICATION_EMAIL_MAX
338 + ),
339 + );
340 + }
341 +
342 + return $out;
343 +}
344 +
345 +/**
346 + * The stored recipient list, ready to hand to wp_mail() (plan 2f131a).
347 + *
348 + * Fallback rule, and it is narrow on purpose: an EMPTY field falls back to the
349 + * site admin address, because that is the documented behaviour and an owner who
350 + * never filled the field in still wants their notifications. A field holding
351 + * something unusable does NOT fall back — it sends nowhere, exactly as before
352 + * this plan. Falling back on bad input would mean a typo silently redirects a
353 + * store's transcripts to a different mailbox than the one on screen.
354 + *
355 + * @param array|null $options mxchat_transcripts_options, or null to read it.
356 + * @return string[] Recipients; empty means do not send.
357 + */
358 +public static function notification_recipients($options = null) {
359 + if (!is_array($options)) {
360 + $options = get_option('mxchat_transcripts_options', array());
361 + if (!is_array($options)) {
362 + $options = array();
363 + }
364 + }
365 +
366 + $raw = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : '';
367 + $raw = is_scalar($raw) ? trim((string) $raw) : '';
368 +
369 + if ($raw === '') {
370 + $admin = get_option('admin_email');
371 + return is_email($admin) ? array($admin) : array();
372 + }
373 +
374 + $parsed = self::parse_notification_emails($raw);
375 + return $parsed['error'] === '' ? $parsed['emails'] : array();
376 +}
377 +
378 +/**
42 379 * Centralized embedding model registry. Single source of truth for dimensions
43 380 * and provider, so model-switch protection logic doesn't drift across files.
44 381 */
45 382 public static function embedding_model_registry() {
@@ -150,8 +487,45 @@
150 487 return (strlen($id) === 11) ? $id : '';
151 488 }
152 489
153 490 /**
491 + * plan-mxchat-20260813-f52492 — video-card gating.
492 + *
493 + * Two standalone options (NOT mxchat_options — they skip the sanitize/autosave
494 + * traps entirely), read here so the gate in the integrator and the fields on
495 + * Knowledge -> Chunking & Retrieval can never disagree about a default.
496 + *
497 + * Master switch. Default ON: the card is existing behavior, and this is an
498 + * opt-out for owners who never want one, not a new feature to opt into.
499 + */
500 +public static function video_embed_enabled() {
501 + return get_option('mxchat_video_embed_enabled', 'on') === 'on';
502 +}
503 +
504 +/**
505 + * The video card's OWN confidence floor, as a 0-1 cosine — deliberately not
506 + * the site-wide Similarity Threshold (default 35). "Good enough to quote in
507 + * the answer" and "good enough to put a video on screen" are different
508 + * questions: retrieval is allowed to be generous because the model still
509 + * decides what to say, whereas the card is asserted to the visitor with no
510 + * such filter. Stored as an int percentage to match the site-wide slider's
511 + * convention; the default (55) sits above it on purpose.
512 + *
513 + * MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT is the single source of that number.
514 + */
515 +public static function video_embed_threshold() {
516 + $stored = get_option('mxchat_video_embed_threshold', null);
517 + $percent = ($stored === null || $stored === '')
518 + ? MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT
519 + : (int) $stored;
520 + if ($percent < 0) { $percent = 0; }
521 + if ($percent > 100) { $percent = 100; }
522 + // Cast: PHP evaluates 100/100 to int(1), so an unclamped return type would
523 + // vary with the stored value. Callers compare against a cosine — keep it float.
524 + return (float) $percent / 100;
525 +}
526 +
527 +/**
154 528 * UPDATED: Submit or update content (and its embedding) in the database.
155 529 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
156 530 *
157 531 * @param string $content The content to be embedded.
@@ -161,9 +535,9 @@
161 535 * @param string $bot_id The bot ID for multi-bot support
162 536 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
163 537 * @return bool|WP_Error True on success, WP_Error on failure
164 538 */
165 -public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
539 +public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content', $title = '') {
166 540 global $wpdb;
167 541 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
168 542
169 543 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
@@ -168,10 +542,18 @@
168 542
169 543 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
170 544 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
171 545
172 - // Sanitize the source URL
173 - $source_url = esc_url_raw($source_url);
546 + // Sanitize the source URL. Internal identities (mxchat:// manual docs,
547 + // upload:// file uploads) are NOT web URLs — esc_url_raw EMPTIES them
548 + // because its protocol list is statically cached and effectively
549 + // unfilterable (measured, plan 945406 / 0485e5) — sanitize those as text
550 + // so upserts stay keyed to a stable identity across re-imports.
551 + if (preg_match('#^(mxchat|upload)://#i', $source_url)) {
552 + $source_url = sanitize_text_field($source_url);
553 + } else {
554 + $source_url = esc_url_raw($source_url);
555 + }
174 556
175 557 // Sanitize content_type
176 558 $content_type = sanitize_key($content_type);
177 559 if (empty($content_type)) {
@@ -177,8 +559,14 @@
177 559 if (empty($content_type)) {
178 560 $content_type = 'content'; // Fallback for backwards compatibility
179 561 }
180 562
563 + // Page title captured by the importer (plan d9ee66), registered under the
564 + // SANITIZED url so the storage writers below find it by the same key.
565 + if (is_string($title) && trim($title) !== '') {
566 + self::set_source_title($source_url, $title);
567 + }
568 +
181 569 // Just ensure UTF-8 validity without aggressive escaping
182 570 $safe_content = wp_check_invalid_utf8($content);
183 571 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
184 572 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
@@ -186,9 +574,16 @@
186 574 // Check if chunking should be applied
187 575 $chunker = MxChat_Chunker::from_settings();
188 576 if ($chunker->should_chunk($safe_content)) {
189 577 //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
190 - return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
578 + $chunk_result = self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
579 + // Vector Store mirror gets the entry WHOLE — one file per KB entry,
580 + // OpenAI chunks server-side; local chunking is our own embedding
581 + // concern and never reaches the store (plan 15b5c6).
582 + if ($chunk_result === true && class_exists('MxChat_Vectorstore_Manager')) {
583 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
584 + }
585 + return $chunk_result;
191 586 }
192 587
193 588 // UPDATED: Generate the embedding using bot-specific configuration
194 589 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
@@ -206,18 +601,175 @@
206 601 // UPDATED: Check if Pinecone is enabled for this specific bot
207 602 if (self::is_pinecone_enabled_for_bot($bot_id)) {
208 603 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
209 604 // Store in Pinecone only
210 - return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
605 + $result = self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
606 + // If this URL previously stored as CHUNKED content and the new content fits in a
607 + // single vector, the upsert above only overwrote the base id — the old
608 + // md5(url)_chunk_N vectors would keep serving the stale text. Sweep them.
609 + // Only for a real source_url: chunk ids derive from it, and md5('') is shared
610 + // by legacy URL-less entries so a blind sweep there could hit other entries.
611 + if ($result === true && !empty($source_url)) {
612 + self::cleanup_pinecone_chunk_stragglers($source_url, $bot_id);
613 + }
614 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
615 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
616 + }
617 + return $result;
211 618 } else {
212 619 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
213 620 // Store in WordPress database only
214 621 $embedding_vector_serialized = maybe_serialize($embedding_vector);
215 - return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
622 + $result = self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
623 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
624 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
625 + }
626 + return $result;
216 627 }
217 628 }
218 629
630 +/* -------------------------------------------------------------------------
631 + * Source titles (3.2.23, plan d9ee66)
632 + *
633 + * A knowledge entry imported from a URL or sitemap remembers the page's own
634 + * title, so an add-on that names a source (AI Search's source cards) can show
635 + * "About Us" instead of a guess from the slug ("Aboutus"). Stored in the
636 + * `title` column of the WP knowledge table and as `title` metadata on Pinecone
637 + * vectors / documents. The importer hands the title to submit_content_to_db(),
638 + * which registers it under the source URL; the writers look it up by that key.
639 + * ---------------------------------------------------------------------- */
640 +
641 +/** @var array<string,string> source_url => title, for the current request */
642 +private static $source_titles = array();
643 +
644 +public static function set_source_title($source_url, $title) {
645 + $key = trim((string) $source_url);
646 + $title = self::clean_title($title);
647 + if ($key === '' || $title === '') {
648 + return;
649 + }
650 + self::$source_titles[$key] = $title;
651 +}
652 +
653 +public static function source_title_for($source_url) {
654 + $key = trim((string) $source_url);
655 + return ($key !== '' && isset(self::$source_titles[$key])) ? self::$source_titles[$key] : '';
656 +}
657 +
658 +/** Entities decoded, tags gone, whitespace collapsed, capped at the column width. */
659 +public static function clean_title($title) {
660 + $title = html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5, 'UTF-8');
661 + $title = wp_strip_all_tags($title);
662 + $title = trim((string) preg_replace('/\s+/u', ' ', $title));
663 + if (mb_strlen($title, 'UTF-8') > 255) {
664 + $title = rtrim(mb_substr($title, 0, 255, 'UTF-8'));
665 + }
666 + return $title;
667 +}
668 +
219 669 /**
670 + * The page's own title from fetched HTML: og:title, else <title> with a
671 + * trailing "| Site Name" style suffix removed, else the first <h1>. Returns ''
672 + * when nothing usable is there, so the caller keeps its own fallback.
673 + */
674 +public static function extract_page_title($html) {
675 + $html = (string) $html;
676 + if ($html === '') {
677 + return '';
678 + }
679 + $head = substr($html, 0, 200000);
680 + $site = '';
681 + if (preg_match('/<meta\s+[^>]*property=["\']og:site_name["\'][^>]*content=["\']([^"\']{1,120})["\']/i', $head, $m)
682 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,120})["\'][^>]*property=["\']og:site_name["\']/i', $head, $m)) {
683 + $site = self::clean_title($m[1]);
684 + }
685 + $candidate = '';
686 + if (preg_match('/<meta\s+[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\']{1,500})["\']/i', $head, $m)
687 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,500})["\'][^>]*property=["\']og:title["\']/i', $head, $m)) {
688 + $candidate = self::clean_title($m[1]);
689 + }
690 + if ($candidate === '' && preg_match('/<title[^>]*>(.*?)<\/title>/is', $head, $m)) {
691 + $candidate = self::clean_title($m[1]);
692 + }
693 + if ($candidate !== '') {
694 + $candidate = self::strip_site_suffix($candidate, $site);
695 + }
696 + if ($candidate === '' && preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $m)) {
697 + $candidate = self::clean_title($m[1]);
698 + }
699 + return $candidate;
700 +}
701 +
702 +/**
703 + * "About Us | Just Facts" -> "About Us". Only a TRAILING segment after a spaced
704 + * separator goes, only when what is left is a real title (3+ characters) and the
705 + * removed part is short enough to be a site name; a known og:site_name wins.
706 + * Byte offsets throughout: every separator starts with an ASCII space, so a cut
707 + * there never lands inside a multibyte character.
708 + */
709 +private static function strip_site_suffix($title, $site_name = '') {
710 + $seps = array(' | ', ' – ', ' — ', ' - ', ' :: ', ' · ', ' » ', ' • ');
711 + if ($site_name !== '') {
712 + foreach ($seps as $sep) {
713 + $tail = $sep . $site_name;
714 + if (strlen($title) > strlen($tail) && substr($title, -strlen($tail)) === $tail) {
715 + return trim(substr($title, 0, strlen($title) - strlen($tail)));
716 + }
717 + }
718 + }
719 + $best_pos = -1;
720 + $best_sep = '';
721 + foreach ($seps as $sep) {
722 + $pos = strrpos($title, $sep);
723 + if ($pos !== false && $pos > $best_pos) {
724 + $best_pos = $pos;
725 + $best_sep = $sep;
726 + }
727 + }
728 + if ($best_pos > 0) {
729 + $left = trim(substr($title, 0, $best_pos));
730 + $right = trim(substr($title, $best_pos + strlen($best_sep)));
731 + if (mb_strlen($left, 'UTF-8') >= 3 && $right !== '' && mb_strlen($right, 'UTF-8') <= 60) {
732 + return $left;
733 + }
734 + }
735 + return $title;
736 +}
737 +
738 +/**
739 + * Make sure the knowledge table has the `title` column (3.2.23, plan d9ee66).
740 + * Activation adds it; this is the safety net for a copy updated in place, and
741 + * the one place add-ons ask "may I read/write title?". A positive answer is
742 + * remembered in an option, so after the first time this costs one option read.
743 + */
744 +public static function ensure_kb_title_column() {
745 + global $wpdb;
746 + static $ready = null;
747 + if ($ready !== null) {
748 + return $ready;
749 + }
750 + if (get_option('mxchat_kb_title_column') === '1') {
751 + $ready = true;
752 + return true;
753 + }
754 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
755 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
756 + if (empty($cols)) {
757 + $ready = false;
758 + return false;
759 + }
760 + if (!in_array('title', $cols, true)) {
761 + $wpdb->query("ALTER TABLE {$table} ADD COLUMN title VARCHAR(255) DEFAULT NULL AFTER source_url");
762 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
763 + }
764 + $ready = in_array('title', $cols, true);
765 + if ($ready) {
766 + update_option('mxchat_kb_title_column', '1', false);
767 + }
768 + return $ready;
769 +}
770 +
771 +/**
220 772 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
221 773 */
222 774 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
223 775 // For default bot or when multi-bot is not active, use original method
@@ -329,11 +881,15 @@
329 881 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
330 882 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
331 883 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
332 884 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
885 + // upload:// identities (admin document/PDF uploads, plan 0485e5) are stable
886 + // and deduplicable — treat them like URLs so a re-upload UPDATES the row
887 + // instead of minting a fresh manual identity (which would duplicate).
888 + $has_stable_identity = $has_url_scheme || (!empty($source_url) && preg_match('#^upload://#i', $source_url));
333 889 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
334 890 $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
335 - $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
891 + $is_manual_content = empty($source_url) || $source_url === '' || !$has_stable_identity || $is_legacy_mxchat_url;
336 892
337 893 if ($is_manual_content) {
338 894 // Generate unique identifier for manual content to prevent overwrites
339 895 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
@@ -354,8 +910,13 @@
354 910 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
355 911 }
356 912 // ===== END FIX =====
357 913
914 + // Page title captured at import (plan d9ee66): written only when the
915 + // importer supplied one, so a re-index without a title keeps the old value.
916 + $title_value = $is_manual_content ? '' : self::source_title_for($source_url);
917 + $title_ready = ($title_value !== '') && self::ensure_kb_title_column();
918 +
358 919 // Progressive fallback mechanism for problematic content
359 920 $attempt = 1;
360 921 $max_attempts = 3;
361 922 $current_content = $safe_content;
@@ -366,39 +927,41 @@
366 927 if ($existing_id) {
367 928 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
368 929
369 930 // Update the existing row - UPDATED 2.5.6: Added content_type
370 - $result = $wpdb->update(
371 - $table_name,
372 - array(
373 - 'url' => $source_url,
374 - 'article_content' => $current_content,
375 - 'embedding_vector' => $embedding_vector_serialized,
376 - 'source_url' => $source_url,
377 - 'content_type' => $content_type,
378 - 'timestamp' => current_time('mysql'),
379 - ),
380 - array('id' => $existing_id),
381 - array('%s','%s','%s','%s','%s','%s'),
382 - array('%d')
931 + $row = array(
932 + 'url' => $source_url,
933 + 'article_content' => $current_content,
934 + 'embedding_vector' => $embedding_vector_serialized,
935 + 'source_url' => $source_url,
936 + 'content_type' => $content_type,
937 + 'timestamp' => current_time('mysql'),
383 938 );
939 + $formats = array('%s','%s','%s','%s','%s','%s');
940 + if ($title_ready) {
941 + $row['title'] = $title_value;
942 + $formats[] = '%s';
943 + }
944 + $result = $wpdb->update($table_name, $row, array('id' => $existing_id), $formats, array('%d'));
384 945 } else {
385 946 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
386 947 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
387 948
388 949 // Insert a new row - UPDATED 2.5.6: Added content_type
389 - $result = $wpdb->insert(
390 - $table_name,
391 - array(
392 - 'url' => $source_url, // Now unique for manual content
393 - 'article_content' => $current_content,
394 - 'embedding_vector' => $embedding_vector_serialized,
395 - 'source_url' => $source_url, // Now unique for manual content
396 - 'content_type' => $content_type,
397 - 'timestamp' => current_time('mysql'),
398 - ),
399 - array('%s','%s','%s','%s','%s','%s')
950 + $row = array(
951 + 'url' => $source_url, // Now unique for manual content
952 + 'article_content' => $current_content,
953 + 'embedding_vector' => $embedding_vector_serialized,
954 + 'source_url' => $source_url, // Now unique for manual content
955 + 'content_type' => $content_type,
956 + 'timestamp' => current_time('mysql'),
400 957 );
958 + $formats = array('%s','%s','%s','%s','%s','%s');
959 + if ($title_ready) {
960 + $row['title'] = $title_value;
961 + $formats[] = '%s';
962 + }
963 + $result = $wpdb->insert($table_name, $row, $formats);
401 964 }
402 965
403 966 if ($result === false) {
404 967 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
@@ -457,10 +1020,11 @@
457 1020 // ===== UPDATED: Handle manual content with unique vector IDs =====
458 1021 if ($vector_id) {
459 1022 // Use provided vector ID
460 1023 //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
461 - } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
462 - // For URLs, use URL-based ID (existing behavior)
1024 + } elseif (!empty($url) && preg_match('#^(https?|upload)://#i', $url)) {
1025 + // For URLs — and stable upload:// identities (plan 0485e5) — use an
1026 + // identity-derived ID so a re-import upserts the same vector.
463 1027 $vector_id = md5($url);
464 1028 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
465 1029 } else {
466 1030 // For manual content (empty/no URL scheme), generate unique ID
@@ -521,9 +1085,23 @@
521 1085 'last_updated' => time(),
522 1086 'created_at' => time(), // Add creation timestamp
523 1087 'bot_id' => $bot_id, // Add bot identification
524 1088 );
525 -
1089 + $page_title = self::source_title_for($url);
1090 + if ($page_title !== '') {
1091 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1092 + }
1093 +
1094 + // Document index (plan 362c31): same id, same metadata, through the
1095 + // documents API. A site on the classic vector index never enters here.
1096 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1097 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $content, $embedding_vector, $metadata, array(
1098 + 'api_key' => $api_key,
1099 + 'host' => $host,
1100 + 'namespace' => $namespace,
1101 + ));
1102 + }
1103 +
526 1104 $vector_data = array(
527 1105 'id' => $vector_id,
528 1106 'values' => $embedding_vector,
529 1107 'metadata' => $metadata
@@ -657,8 +1235,25 @@
657 1235 return array('ok' => true, 'api_key' => $api_key, 'reason' => '', 'provider' => $provider);
658 1236 }
659 1237
660 1238 /**
1239 + * Public QUERY-side entry point (plan 876edb). The chat pipeline's
1240 + * MxChat_Integrator::mxchat_generate_embedding() adapter routes through here
1241 + * so the query and index sides share ONE provider-routing implementation —
1242 + * the same endpoints, request bodies, and stamping semantics. The Integrator
1243 + * keeps its own error vocabulary by translating the WP_Error this returns
1244 + * (see the structured error data on every failure path below).
1245 + *
1246 + * @param string $text The text to be embedded.
1247 + * @param string $api_key Caller-resolved API key (per-bot on the query side).
1248 + * @param string $bot_id The bot ID for multi-bot support.
1249 + * @return array|WP_Error The embedding vector, or WP_Error carrying the reason.
1250 + */
1251 +public static function generate_query_embedding($text, $api_key, $bot_id = 'default') {
1252 + return self::generate_embedding($text, $api_key, $bot_id);
1253 +}
1254 +
1255 +/**
661 1256 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
662 1257 *
663 1258 * @param string $text The text to be embedded.
664 1259 * @param string $api_key The API key used for generating embeddings.
@@ -680,10 +1275,12 @@
680 1275 // below is untouched.
681 1276 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
682 1277 $custom = self::generate_embedding_custom($text, $options);
683 1278 // The custom path already returns a human-readable error string —
684 - // carry it instead of collapsing to null (plan 4a7c0a).
685 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom);
1279 + // carry it instead of collapsing to null (plan 4a7c0a). The 'custom'
1280 + // branch marker lets the Integrator adapter map the string back onto
1281 + // its own error codes (876edb).
1282 + return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom, array('branch' => 'custom'));
686 1283 }
687 1284
688 1285 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
689 1286
@@ -695,10 +1292,13 @@
695 1292 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
696 1293 $api_key = $options['gemini_api_key'] ?? '';
697 1294 } else {
698 1295 $endpoint = 'https://api.openai.com/v1/embeddings';
699 - // Use the bot-specific API key or fallback to passed API key
700 - $api_key = $options['api_key'] ?? $api_key;
1296 + // Prefer the caller-resolved key when one was passed — the query side
1297 + // resolves per-bot keys at its call sites (integrator adapter, 876edb).
1298 + // Index callers pass the preflight key, which equals this options read,
1299 + // so nothing changes for them.
1300 + $api_key = !empty($api_key) ? $api_key : ($options['api_key'] ?? '');
701 1301 }
702 1302
703 1303 // Prepare request body based on provider
704 1304 if (strpos($selected_model, 'gemini-embedding') === 0) {
@@ -753,9 +1353,14 @@
753 1353 $message = 'Embedding request failed (connection): ' . $response->get_error_message();
754 1354 if (class_exists('MxChat_Admin')) {
755 1355 MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
756 1356 }
757 - return new WP_Error('embedding_failed', $message);
1357 + return new WP_Error('embedding_failed', $message, array(
1358 + 'branch' => 'cloud',
1359 + 'kind' => 'connection',
1360 + 'reason' => $response->get_error_message(),
1361 + 'model' => $selected_model,
1362 + ));
758 1363 }
759 1364
760 1365 $response_body = json_decode(wp_remote_retrieve_body($response), true);
761 1366
@@ -820,9 +1425,18 @@
820 1425 'bot_id' => $bot_id,
821 1426 ));
822 1427 }
823 1428
824 - return new WP_Error('embedding_failed', $message);
1429 + // Structured data so the Integrator's query-side adapter can rebuild its
1430 + // typed error contract (auth/rate-limit/quota/invalid-response) without a
1431 + // second transport implementation (876edb). Additive — message unchanged.
1432 + return new WP_Error('embedding_failed', $message, array(
1433 + 'branch' => 'cloud',
1434 + 'status' => $status,
1435 + 'error_type' => (is_array($decoded) && isset($decoded['error']['type']) && is_string($decoded['error']['type'])) ? $decoded['error']['type'] : '',
1436 + 'reason' => $reason,
1437 + 'model' => $selected_model,
1438 + ));
825 1439 }
826 1440
827 1441 /**
828 1442 * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
@@ -877,9 +1491,13 @@
877 1491 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
878 1492 'timeout' => 60,
879 1493 ]);
880 1494 if (is_wp_error($response)) {
881 - return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
1495 + return self::log_custom_embedding_failure(
1496 + 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message(),
1497 + $model,
1498 + $api_key
1499 + );
882 1500 }
883 1501
884 1502 $status = wp_remote_retrieve_response_code($response);
885 1503 $body = json_decode(wp_remote_retrieve_body($response), true);
@@ -884,9 +1502,14 @@
884 1502 $status = wp_remote_retrieve_response_code($response);
885 1503 $body = json_decode(wp_remote_retrieve_body($response), true);
886 1504 if ($status !== 200) {
887 1505 $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
888 - return 'Custom embedding endpoint error: ' . $msg;
1506 + return self::log_custom_embedding_failure(
1507 + 'Custom embedding endpoint error: ' . $msg,
1508 + $model,
1509 + $api_key,
1510 + (int) $status
1511 + );
889 1512 }
890 1513 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
891 1514 // Stamp the custom model identity so the active-embedding-model mismatch
892 1515 // warning reflects the real (custom) model rather than the built-in setting.
@@ -892,12 +1515,42 @@
892 1515 // warning reflects the real (custom) model rather than the built-in setting.
893 1516 self::stamp_active_embedding_model('custom:' . $model);
894 1517 return $body['data'][0]['embedding'];
895 1518 }
896 - return 'Invalid embedding response from custom provider.';
1519 + return self::log_custom_embedding_failure('Invalid embedding response from custom provider.', $model, $api_key);
897 1520 }
898 1521
899 1522 /**
1523 + * Record a custom-provider embedding failure in the Debug Mode log, then
1524 + * return the message unchanged so callers keep their string-error contract.
1525 + * The cloud branch has logged its failures since 4a7c0a; the custom branch
1526 + * never did, so chat-side failures on Custom-provider installs were
1527 + * invisible to Debug Mode despite the 3.2.18 readme saying otherwise
1528 + * (plan 71e4b6). Same scrub-then-log shape as embedding_failure_error().
1529 + *
1530 + * @param string $message Human-readable failure (the caller's return value).
1531 + * @param string $model Resolved custom embedding model.
1532 + * @param string $api_key Scrubbed out of the logged message if it ever appears.
1533 + * @param int $status HTTP status when one was received, 0 otherwise.
1534 + * @return string The (scrubbed) message.
1535 + */
1536 +private static function log_custom_embedding_failure($message, $model, $api_key, $status = 0) {
1537 + if (is_string($api_key) && $api_key !== '') {
1538 + $message = str_replace($api_key, '[redacted]', $message);
1539 + }
1540 +
1541 + if (class_exists('MxChat_Admin')) {
1542 + $context = array('model' => 'custom:' . $model);
1543 + if ($status > 0) {
1544 + $context['status'] = $status;
1545 + }
1546 + MxChat_Admin::mxchat_log_debug('embedding_error', $message, $context);
1547 + }
1548 +
1549 + return $message;
1550 +}
1551 +
1552 +/**
900 1553 * Submit content as multiple chunks
901 1554 *
902 1555 * Splits large content into chunks, generates embeddings for each,
903 1556 * and stores them with chunk metadata for later reassembly.
@@ -916,10 +1569,26 @@
916 1569
917 1570 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
918 1571 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
919 1572
920 - // First, delete any existing chunks for this URL (clean slate)
1573 + // URL-less (manual) content needs a minted identity BEFORE chunk ids are derived:
1574 + // every chunk id is md5(source_url)_chunk_N, so with source_url = '' EVERY long
1575 + // manual document shared the md5('') prefix — and the clean-slate delete below
1576 + // wiped the PREVIOUS manual entry's chunks each time a new one was added. The
1577 + // single-vector paths already mint (mxchat:// in WP, manual_* in Pinecone); this
1578 + // was the one storage path that didn't. Keep an mxchat:// identity if the caller
1579 + // already carries one.
1580 + if (strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, 'upload://') !== 0 && !preg_match('#^https?://#i', $source_url)) {
1581 + $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
1582 + }
1583 +
1584 + // First, delete any existing chunks for this URL (clean slate).
1585 + // Mirror-suspended: this is a re-store, not an entry removal — the Vector
1586 + // Store file is replaced (or kept, on hash match) by the caller's
1587 + // sync_upsert_entry after storage succeeds.
1588 + self::$vectorstore_mirror_suspended = true;
921 1589 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
1590 + self::$vectorstore_mirror_suspended = false;
922 1591 if (is_wp_error($delete_result)) {
923 1592 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
924 1593 // Continue anyway - we'll overwrite with upsert
925 1594 }
@@ -1108,9 +1777,22 @@
1108 1777 'last_updated' => time(),
1109 1778 'created_at' => time(),
1110 1779 'bot_id' => $bot_id,
1111 1780 );
1781 + $page_title = self::source_title_for($source_url);
1782 + if ($page_title !== '') {
1783 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1784 + }
1112 1785
1786 + // Document index (plan 362c31): same chunk id and metadata, documents API.
1787 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1788 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $chunk_text, $embedding_vector, $metadata, array(
1789 + 'api_key' => $api_key,
1790 + 'host' => $host,
1791 + 'namespace' => $namespace,
1792 + ));
1793 + }
1794 +
1113 1795 $vector_data = array(
1114 1796 'id' => $vector_id,
1115 1797 'values' => $embedding_vector,
1116 1798 'metadata' => $metadata
@@ -1153,20 +1835,25 @@
1153 1835 global $wpdb;
1154 1836
1155 1837 // For chunks, we always insert new rows (no duplicate checking)
1156 1838 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
1157 - $result = $wpdb->insert(
1158 - $table_name,
1159 - array(
1160 - 'url' => $source_url,
1161 - 'article_content' => $content_with_metadata,
1162 - 'embedding_vector' => $embedding_vector_serialized,
1163 - 'source_url' => $source_url,
1164 - 'content_type' => $content_type,
1165 - 'timestamp' => current_time('mysql')
1166 - ),
1167 - array('%s', '%s', '%s', '%s', '%s', '%s')
1839 + $row = array(
1840 + 'url' => $source_url,
1841 + 'article_content' => $content_with_metadata,
1842 + 'embedding_vector' => $embedding_vector_serialized,
1843 + 'source_url' => $source_url,
1844 + 'content_type' => $content_type,
1845 + 'timestamp' => current_time('mysql')
1168 1846 );
1847 + $formats = array('%s', '%s', '%s', '%s', '%s', '%s');
1848 + // Every chunk row carries the page title too (plan d9ee66) - readers group by
1849 + // source_url and may pick any chunk as the representative.
1850 + $page_title = self::source_title_for($source_url);
1851 + if ($page_title !== '' && self::ensure_kb_title_column()) {
1852 + $row['title'] = $page_title;
1853 + $formats[] = '%s';
1854 + }
1855 + $result = $wpdb->insert($table_name, $row, $formats);
1169 1856
1170 1857 if ($result === false) {
1171 1858 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1172 1859 }
@@ -1183,8 +1870,14 @@
1183 1870 */
1184 1871 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
1185 1872 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
1186 1873
1874 + // Entry removal — mirror it to the Vector Store (unless a storage routine
1875 + // is mid-re-store, see $vectorstore_mirror_suspended).
1876 + if (!self::$vectorstore_mirror_suspended && class_exists('MxChat_Vectorstore_Manager')) {
1877 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
1878 + }
1879 +
1187 1880 if (self::is_pinecone_enabled_for_bot($bot_id)) {
1188 1881 return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1189 1882 } else {
1190 1883 return self::delete_wordpress_chunks_by_url($source_url);
@@ -1193,10 +1886,16 @@
1193 1886
1194 1887 /**
1195 1888 * Delete all chunks for a URL from Pinecone
1196 1889 */
1197 -private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1198 - // Get Pinecone configuration
1890 +/**
1891 + * Delete leftover md5(url)_chunk_N vectors after a URL's content was re-stored
1892 + * as a SINGLE vector (content shrank below the chunk threshold on edit/re-import).
1893 + * Unlike delete_pinecone_chunks_by_url this leaves the base id alone — the caller
1894 + * just upserted the new content there. Failure is logged, not fatal: the save
1895 + * itself succeeded, and the next save retries the sweep.
1896 + */
1897 +private static function cleanup_pinecone_chunk_stragglers($source_url, $bot_id) {
1199 1898 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1200 1899 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1201 1900 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1202 1901 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
@@ -1215,21 +1914,30 @@
1215 1914 }
1216 1915 }
1217 1916
1218 1917 if (empty($host) || empty($api_key)) {
1219 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
1918 + return;
1220 1919 }
1221 1920
1222 - $base_vector_id = md5($source_url);
1223 - $vectors_to_delete = array();
1921 + // Document index (plan 362c31): list the chunk ids and delete them through the documents API.
1922 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1923 + $docs_cfg = array('api_key' => $api_key, 'host' => $host, 'namespace' => $namespace);
1924 + $doc_stragglers = MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $docs_cfg);
1925 + if (!empty($doc_stragglers)) {
1926 + $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_stragglers, $docs_cfg);
1927 + if (is_wp_error($doc_result) && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
1928 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to sweep stale chunk documents after single-vector re-store', array('source_url' => $source_url, 'bot_id' => $bot_id, 'count' => count($doc_stragglers)));
1929 + }
1930 + }
1931 + return;
1932 + }
1224 1933
1225 - // Add the original single-vector ID (for non-chunked content)
1226 - $vectors_to_delete[] = $base_vector_id;
1934 + $stragglers = array();
1227 1935
1228 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1229 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
1936 + // Pinecone /vectors/list is a GET endpoint with query-string parameters (a POST
1937 + // answers 200-with-an-empty-body, which reads as "no stragglers").
1230 1938 $query_params = array(
1231 - 'prefix' => $base_vector_id . '_chunk_',
1939 + 'prefix' => md5($source_url) . '_chunk_',
1232 1940 'limit' => 100,
1233 1941 );
1234 1942 if (!empty($namespace)) {
1235 1943 $query_params['namespace'] = $namespace;
@@ -1236,9 +1944,8 @@
1236 1944 }
1237 1945
1238 1946 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1239 1947
1240 - // Paginate in case a URL has more than 100 chunks.
1241 1948 do {
1242 1949 $list_response = wp_remote_get($list_url, array(
1243 1950 'headers' => array(
1244 1951 'Api-Key' => $api_key,
@@ -1254,9 +1961,9 @@
1254 1961 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1255 1962 if (!empty($list_data['vectors'])) {
1256 1963 foreach ($list_data['vectors'] as $vector) {
1257 1964 if (isset($vector['id'])) {
1258 - $vectors_to_delete[] = $vector['id'];
1965 + $stragglers[] = $vector['id'];
1259 1966 }
1260 1967 }
1261 1968 }
1262 1969
@@ -1268,8 +1975,188 @@
1268 1975 $query_params['paginationToken'] = $next_token;
1269 1976 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1270 1977 } while (true);
1271 1978
1979 + if (empty($stragglers)) {
1980 + return;
1981 + }
1982 +
1983 + $delete_body = array('ids' => $stragglers);
1984 + if (!empty($namespace)) {
1985 + $delete_body['namespace'] = $namespace;
1986 + }
1987 +
1988 + $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
1989 + 'headers' => array(
1990 + 'Api-Key' => $api_key,
1991 + 'accept' => 'application/json',
1992 + 'content-type' => 'application/json'
1993 + ),
1994 + 'body' => wp_json_encode($delete_body),
1995 + 'timeout' => 30
1996 + ));
1997 +
1998 + if ((is_wp_error($delete_response) || wp_remote_retrieve_response_code($delete_response) !== 200)
1999 + && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
2000 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to sweep stale chunk vectors after single-vector re-store', array('source_url' => $source_url, 'bot_id' => $bot_id, 'count' => count($stragglers)));
2001 + }
2002 +}
2003 +
2004 +/**
2005 + * Resolve the Pinecone credentials for a bot: the bot's own config when the
2006 + * multi-bot add-on supplies one, else the site-wide options. Extracted so the
2007 + * delete path and the count path can never resolve different credentials.
2008 + *
2009 + * @param string $bot_id
2010 + * @return array{api_key:string,host:string,namespace:string}
2011 + */
2012 +private static function pinecone_config_for_bot($bot_id) {
2013 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2014 + $bot_pinecone_config = array();
2015 + } else {
2016 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2017 + }
2018 +
2019 + if (empty($bot_pinecone_config)) {
2020 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
2021 + return array(
2022 + 'api_key' => $pinecone_options['mxchat_pinecone_api_key'] ?? '',
2023 + 'host' => $pinecone_options['mxchat_pinecone_host'] ?? '',
2024 + 'namespace' => $pinecone_options['mxchat_pinecone_namespace'] ?? '',
2025 + // 'vector' | 'document' (plan 362c31) — the delete / count paths branch on it.
2026 + 'index_type' => (($pinecone_options['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2027 + );
2028 + }
2029 +
2030 + return array(
2031 + 'api_key' => $bot_pinecone_config['api_key'] ?? '',
2032 + 'host' => $bot_pinecone_config['host'] ?? '',
2033 + 'namespace' => $bot_pinecone_config['namespace'] ?? '',
2034 + 'index_type' => (($bot_pinecone_config['index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2035 + );
2036 +}
2037 +
2038 +/**
2039 + * List every Pinecone vector id under a prefix, following pagination.
2040 + *
2041 + * Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
2042 + * non-200 silently and we end up seeing only the base vector, leaving chunks orphaned.
2043 + *
2044 + * @param string $prefix Vector-id prefix to match.
2045 + * @param array $cfg From pinecone_config_for_bot().
2046 + * @return array List of vector ids (empty when none match or the API is unreachable).
2047 + */
2048 +private static function pinecone_list_ids_by_prefix($prefix, array $cfg) {
2049 + // Document index (plan 362c31): the documents API lists by prefix too.
2050 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2051 + return MxChat_Pinecone_Documents::list_ids($prefix, $cfg);
2052 + }
2053 +
2054 + $ids = array();
2055 +
2056 + $query_params = array(
2057 + 'prefix' => $prefix,
2058 + 'limit' => 100,
2059 + );
2060 + if (!empty($cfg['namespace'])) {
2061 + $query_params['namespace'] = $cfg['namespace'];
2062 + }
2063 +
2064 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2065 +
2066 + // Paginate in case a URL has more than 100 chunks.
2067 + do {
2068 + $list_response = wp_remote_get($list_url, array(
2069 + 'headers' => array(
2070 + 'Api-Key' => $cfg['api_key'],
2071 + 'accept' => 'application/json',
2072 + ),
2073 + 'timeout' => 30,
2074 + ));
2075 +
2076 + if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
2077 + break;
2078 + }
2079 +
2080 + $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
2081 + if (!empty($list_data['vectors'])) {
2082 + foreach ($list_data['vectors'] as $vector) {
2083 + if (isset($vector['id'])) {
2084 + $ids[] = $vector['id'];
2085 + }
2086 + }
2087 + }
2088 +
2089 + $next_token = $list_data['pagination']['next'] ?? '';
2090 + if (empty($next_token)) {
2091 + break;
2092 + }
2093 +
2094 + $query_params['paginationToken'] = $next_token;
2095 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2096 + } while (true);
2097 +
2098 + return $ids;
2099 +}
2100 +
2101 +/**
2102 + * How many stored pieces a URL currently has, in whichever backend holds it.
2103 + *
2104 + * Branches on the SAME condition delete_chunks_for_url() branches on, so the
2105 + * number reported is the number that call removes. Used by the REST DELETE
2106 + * route to answer "was anything actually there?" without a second delete path.
2107 + *
2108 + * @param string $source_url
2109 + * @param string $bot_id
2110 + * @return int
2111 + */
2112 +public static function count_chunks_for_url($source_url, $bot_id = 'default') {
2113 + if (self::is_pinecone_enabled_for_bot($bot_id)) {
2114 + $cfg = self::pinecone_config_for_bot($bot_id);
2115 + if (empty($cfg['host']) || empty($cfg['api_key'])) {
2116 + return 0;
2117 + }
2118 + // The base id and every "<base>_chunk_N" share the base as a prefix,
2119 + // so one prefix listing covers chunked and non-chunked content alike.
2120 + return count(self::pinecone_list_ids_by_prefix(md5($source_url), $cfg));
2121 + }
2122 +
2123 + global $wpdb;
2124 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2125 +
2126 + return (int) $wpdb->get_var($wpdb->prepare(
2127 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
2128 + $source_url
2129 + ));
2130 +}
2131 +
2132 +private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
2133 + $cfg = self::pinecone_config_for_bot($bot_id);
2134 + $api_key = $cfg['api_key'];
2135 + $host = $cfg['host'];
2136 + $namespace = $cfg['namespace'];
2137 +
2138 + if (empty($host) || empty($api_key)) {
2139 + return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
2140 + }
2141 +
2142 + // Document index (plan 362c31): base id + every <md5>_chunk_N, documents API.
2143 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2144 + $doc_ids = array_merge(array(md5($source_url)), MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $cfg));
2145 + return MxChat_Pinecone_Documents::delete_ids($doc_ids, $cfg);
2146 + }
2147 +
2148 + $base_vector_id = md5($source_url);
2149 + $vectors_to_delete = array();
2150 +
2151 + // Add the original single-vector ID (for non-chunked content)
2152 + $vectors_to_delete[] = $base_vector_id;
2153 +
2154 + $vectors_to_delete = array_merge(
2155 + $vectors_to_delete,
2156 + self::pinecone_list_ids_by_prefix($base_vector_id . '_chunk_', $cfg)
2157 + );
2158 +
1272 2159 if (empty($vectors_to_delete)) {
1273 2160 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1274 2161 return true;
1275 2162 }
@@ -1364,6 +2251,261 @@
1364 2251
1365 2252 $capability = $index_exists ? 'fulltext' : 'like';
1366 2253 update_option('mxchat_hybrid_keyword_capability', $capability);
1367 2254 return $capability;
2255 +}
2256 +
2257 +/**
2258 + * Public entry for re-embedding already-stored content in place (wp mxchat
2259 + * rtl-repair, plan d1e6f7). Thin wrapper so the repair CLI gets the exact
2260 + * provider routing the import path uses — the repaired vector must come from
2261 + * the same model family the bot indexes with, or retrieval stays broken.
2262 + */
2263 +public static function regenerate_embedding($text, $api_key, $bot_id = 'default') {
2264 + return self::generate_embedding($text, $api_key, $bot_id);
2265 +}
2266 +
2267 +/**
2268 + * Restore logical character order in PDF-extracted RTL text (plan 32bf9e).
2269 + *
2270 + * The bundled Smalot parser only un-reverses text runs tagged with the
2271 + * ReversedChars marked-content operator (Word emits it; LibreOffice and most
2272 + * other producers do not), so their Hebrew/Arabic PDFs extract in visual
2273 + * (reversed) order and embed/search as garbage. This is OUR post-processing
2274 + * seam over getText() — the parser itself is never patched (it gets replaced
2275 + * wholesale on library updates).
2276 + *
2277 + * Heuristic and deliberately conservative, per line:
2278 + * - lines without strong RTL codepoints are untouched (a fully-Latin line in
2279 + * an RTL document therefore stays as extracted — accepted limitation);
2280 + * - Arabic presentation forms are a definitive visual-order signal (they only
2281 + * appear in shaped output): de-shape to base letters and reverse;
2282 + * - otherwise flip only on positive evidence — Hebrew final-letter position
2283 + * (a sofit at word START only happens in reversed text) or sentence
2284 + * punctuation position (leading in visual order, trailing in logical);
2285 + * - ambiguous lines are left alone: a conservative miss beats corrupting a
2286 + * Word-produced extraction the parser already handled (the double-flip
2287 + * guard this plan's approval named mandatory).
2288 + *
2289 + * @param string $text One extracted page string, straight from getText().
2290 + * @param string $context Caller tag for the Debug Mode entry (site + page).
2291 + * @return string Text with RTL lines restored to logical order.
2292 + */
2293 +public static function normalize_pdf_rtl($text, $context = '') {
2294 + if (!is_string($text) || '' === $text) {
2295 + return $text;
2296 + }
2297 + // Escape hatch for sites whose PDFs already extract logically.
2298 + if (!apply_filters('mxchat_pdf_rtl_normalize', true, $text)) {
2299 + return $text;
2300 + }
2301 + // Fast bail: nothing RTL anywhere in the page.
2302 + if (!preg_match('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text)) {
2303 + return $text;
2304 + }
2305 +
2306 + $parts = preg_split('/(\R)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
2307 + if (false === $parts) {
2308 + return $text;
2309 + }
2310 +
2311 + $flipped_lines = 0;
2312 + $deshaped_lines = 0;
2313 + foreach ($parts as $i => $part) {
2314 + if ('' === $part || preg_match('/^\R$/u', $part)) {
2315 + continue;
2316 + }
2317 + $was_flipped = false;
2318 + $was_deshaped = false;
2319 + $new = self::pdf_rtl_normalize_line($part, $was_flipped, $was_deshaped);
2320 + if ($new !== $part) {
2321 + $parts[$i] = $new;
2322 + }
2323 + if ($was_flipped) {
2324 + $flipped_lines++;
2325 + }
2326 + if ($was_deshaped) {
2327 + $deshaped_lines++;
2328 + }
2329 + }
2330 +
2331 + if (($flipped_lines || $deshaped_lines) && class_exists('MxChat_Admin')) {
2332 + MxChat_Admin::mxchat_log_debug('pdf_rtl_normalized', 'RTL PDF text restored to logical order', array(
2333 + 'context' => (string) $context,
2334 + 'lines_flipped' => $flipped_lines,
2335 + 'lines_deshaped' => $deshaped_lines,
2336 + 'decision' => 'visual-order extraction detected',
2337 + ));
2338 + }
2339 +
2340 + return implode('', $parts);
2341 +}
2342 +
2343 +/**
2344 + * Normalize one line. Sets $flipped/$deshaped for the caller's debug entry.
2345 + */
2346 +private static function pdf_rtl_normalize_line($line, &$flipped, &$deshaped) {
2347 + $flipped = false;
2348 + $deshaped = false;
2349 +
2350 + if (!preg_match('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $line)) {
2351 + return $line;
2352 + }
2353 +
2354 + $has_forms = (bool) preg_match('/[\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $line);
2355 + $work = $line;
2356 + if ($has_forms) {
2357 + $work = strtr($work, self::pdf_rtl_deshape_map());
2358 + $deshaped = ($work !== $line);
2359 + }
2360 +
2361 + $verdict = 'ambiguous';
2362 + if ($has_forms) {
2363 + // Shaped glyph codepoints only exist in visual-order output.
2364 + $verdict = 'visual';
2365 + } else {
2366 + // Strong-direction dominance gate first: an LTR-dominant line with an
2367 + // embedded RTL word is not flip material.
2368 + $rtl_count = preg_match_all('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}]/u', $work, $m_rtl);
2369 + $ltr_count = preg_match_all('/[A-Za-z]/u', $work, $m_ltr);
2370 + if ($rtl_count < 1 || $rtl_count <= $ltr_count) {
2371 + return $line;
2372 + }
2373 +
2374 + // Hebrew final letters (ך ם ן ף ץ) end words in logical text; one at
2375 + // a word START (Hebrew letter follows, none precedes) is reversal
2376 + // evidence. Positional, so it survives the line being reversed.
2377 + $sofit_initial = preg_match_all('/(?<![\x{05D0}-\x{05EA}])[\x{05DA}\x{05DD}\x{05DF}\x{05E3}\x{05E5}](?=[\x{05D0}-\x{05EA}])/u', $work, $m_i);
2378 + $sofit_terminal = preg_match_all('/(?<=[\x{05D0}-\x{05EA}])[\x{05DA}\x{05DD}\x{05DF}\x{05E3}\x{05E5}](?![\x{05D0}-\x{05EA}])/u', $work, $m_t);
2379 + if ($sofit_initial > $sofit_terminal) {
2380 + $verdict = 'visual';
2381 + } elseif ($sofit_terminal > $sofit_initial) {
2382 + $verdict = 'logical';
2383 + } else {
2384 + // Sentence punctuation lands at the visual LEFT edge of an RTL
2385 + // line, i.e. the START of a visual-order extraction.
2386 + $trimmed = trim($work);
2387 + $starts_punct = (bool) preg_match('/^[.?!:;,]/u', $trimmed);
2388 + $ends_punct = (bool) preg_match('/[.?!:;,]$/u', $trimmed);
2389 + if ($starts_punct && !$ends_punct) {
2390 + $verdict = 'visual';
2391 + } elseif ($ends_punct && !$starts_punct) {
2392 + $verdict = 'logical';
2393 + }
2394 + }
2395 + }
2396 +
2397 + if ('visual' !== $verdict) {
2398 + // Ambiguous or logical: hand back the original line UNLESS we
2399 + // de-shaped (de-shaping alone is always safe — same letters, same
2400 + // order, un-ligated).
2401 + return $deshaped ? $work : $line;
2402 + }
2403 +
2404 + $flipped = true;
2405 + return self::pdf_rtl_flip_line($work);
2406 +}
2407 +
2408 +/**
2409 + * Reverse a visual-order line into logical order: full character reversal,
2410 + * mirror paired punctuation, then re-reverse embedded LTR runs (Latin words
2411 + * and digit sequences, incl. Arabic-Indic digits) so they stay readable.
2412 + */
2413 +private static function pdf_rtl_flip_line($line) {
2414 + $chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY);
2415 + if (false === $chars) {
2416 + return $line;
2417 + }
2418 + $reversed = implode('', array_reverse($chars));
2419 + $reversed = strtr($reversed, array(
2420 + '(' => ')', ')' => '(',
2421 + '[' => ']', ']' => '[',
2422 + '{' => '}', '}' => '{',
2423 + '<' => '>', '>' => '<',
2424 + ));
2425 + $restored = preg_replace_callback(
2426 + '/[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9}](?:[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9} .,\'"%\-:\/]*[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9}])?/u',
2427 + function ($m) {
2428 + $run = preg_split('//u', $m[0], -1, PREG_SPLIT_NO_EMPTY);
2429 + return false === $run ? $m[0] : implode('', array_reverse($run));
2430 + },
2431 + $reversed
2432 + );
2433 + return null === $restored ? $reversed : $restored;
2434 +}
2435 +
2436 +/**
2437 + * Arabic presentation forms (A + B) -> base letters. Built once from range
2438 + * specs rather than ~120 hand-written literal entries; every codepoint in a
2439 + * range maps to the same base sequence (isolated/final/initial/medial forms
2440 + * of one letter are contiguous in the FE70 block).
2441 + */
2442 +private static function pdf_rtl_deshape_map() {
2443 + static $map = null;
2444 + if (null !== $map) {
2445 + return $map;
2446 + }
2447 + $ranges = array(
2448 + // Form B harakat (each pair = standalone + tatweel-joined form).
2449 + array(0xFE70, 0xFE71, array(0x064B)), array(0xFE72, 0xFE72, array(0x064C)),
2450 + array(0xFE74, 0xFE74, array(0x064D)), array(0xFE76, 0xFE77, array(0x064E)),
2451 + array(0xFE78, 0xFE79, array(0x064F)), array(0xFE7A, 0xFE7B, array(0x0650)),
2452 + array(0xFE7C, 0xFE7D, array(0x0651)), array(0xFE7E, 0xFE7F, array(0x0652)),
2453 + // Form B letters.
2454 + array(0xFE80, 0xFE80, array(0x0621)), array(0xFE81, 0xFE82, array(0x0622)),
2455 + array(0xFE83, 0xFE84, array(0x0623)), array(0xFE85, 0xFE86, array(0x0624)),
2456 + array(0xFE87, 0xFE88, array(0x0625)), array(0xFE89, 0xFE8C, array(0x0626)),
2457 + array(0xFE8D, 0xFE8E, array(0x0627)), array(0xFE8F, 0xFE92, array(0x0628)),
2458 + array(0xFE93, 0xFE94, array(0x0629)), array(0xFE95, 0xFE98, array(0x062A)),
2459 + array(0xFE99, 0xFE9C, array(0x062B)), array(0xFE9D, 0xFEA0, array(0x062C)),
2460 + array(0xFEA1, 0xFEA4, array(0x062D)), array(0xFEA5, 0xFEA8, array(0x062E)),
2461 + array(0xFEA9, 0xFEAA, array(0x062F)), array(0xFEAB, 0xFEAC, array(0x0630)),
2462 + array(0xFEAD, 0xFEAE, array(0x0631)), array(0xFEAF, 0xFEB0, array(0x0632)),
2463 + array(0xFEB1, 0xFEB4, array(0x0633)), array(0xFEB5, 0xFEB8, array(0x0634)),
2464 + array(0xFEB9, 0xFEBC, array(0x0635)), array(0xFEBD, 0xFEC0, array(0x0636)),
2465 + array(0xFEC1, 0xFEC4, array(0x0637)), array(0xFEC5, 0xFEC8, array(0x0638)),
2466 + array(0xFEC9, 0xFECC, array(0x0639)), array(0xFECD, 0xFED0, array(0x063A)),
2467 + array(0xFED1, 0xFED4, array(0x0641)), array(0xFED5, 0xFED8, array(0x0642)),
2468 + array(0xFED9, 0xFEDC, array(0x0643)), array(0xFEDD, 0xFEE0, array(0x0644)),
2469 + array(0xFEE1, 0xFEE4, array(0x0645)), array(0xFEE5, 0xFEE8, array(0x0646)),
2470 + array(0xFEE9, 0xFEEC, array(0x0647)), array(0xFEED, 0xFEEE, array(0x0648)),
2471 + array(0xFEEF, 0xFEF0, array(0x0649)), array(0xFEF1, 0xFEF4, array(0x064A)),
2472 + // Form B lam-alef ligatures decompose to two letters.
2473 + array(0xFEF5, 0xFEF6, array(0x0644, 0x0622)), array(0xFEF7, 0xFEF8, array(0x0644, 0x0623)),
2474 + array(0xFEF9, 0xFEFA, array(0x0644, 0x0625)), array(0xFEFB, 0xFEFC, array(0x0644, 0x0627)),
2475 + // Form A: Persian / Urdu letters in common use.
2476 + array(0xFB56, 0xFB59, array(0x067E)), array(0xFB66, 0xFB69, array(0x0679)),
2477 + array(0xFB7A, 0xFB7D, array(0x0686)), array(0xFB88, 0xFB89, array(0x0688)),
2478 + array(0xFB8A, 0xFB8B, array(0x0698)), array(0xFB8E, 0xFB91, array(0x06A9)),
2479 + array(0xFB92, 0xFB95, array(0x06AF)), array(0xFBA6, 0xFBA9, array(0x06C1)),
2480 + array(0xFBAA, 0xFBAD, array(0x06BE)), array(0xFBAE, 0xFBAF, array(0x06D2)),
2481 + array(0xFBFC, 0xFBFF, array(0x06CC)),
2482 + );
2483 + $map = array();
2484 + foreach ($ranges as $range) {
2485 + $base = '';
2486 + foreach ($range[2] as $cp) {
2487 + $base .= self::pdf_rtl_cp_to_utf8($cp);
2488 + }
2489 + for ($cp = $range[0]; $cp <= $range[1]; $cp++) {
2490 + $map[self::pdf_rtl_cp_to_utf8($cp)] = $base;
2491 + }
2492 + }
2493 + return $map;
2494 +}
2495 +
2496 +/**
2497 + * Codepoint to UTF-8 without ext-intl / mbstring entity tricks (PHP 7.2 floor).
2498 + */
2499 +private static function pdf_rtl_cp_to_utf8($cp) {
2500 + if ($cp < 0x80) {
2501 + return chr($cp);
2502 + }
2503 + if ($cp < 0x800) {
2504 + return chr(0xC0 | ($cp >> 6)) . chr(0x80 | ($cp & 0x3F));
2505 + }
2506 + if ($cp < 0x10000) {
2507 + return chr(0xE0 | ($cp >> 12)) . chr(0x80 | (($cp >> 6) & 0x3F)) . chr(0x80 | ($cp & 0x3F));
2508 + }
2509 + return chr(0xF0 | ($cp >> 18)) . chr(0x80 | (($cp >> 12) & 0x3F)) . chr(0x80 | (($cp >> 6) & 0x3F)) . chr(0x80 | ($cp & 0x3F));
1368 2510 }
1369 2511 }