settings = new Settings();
$factory = new EmbeddingsFactory( $this->settings );
$this->embeddings = $factory->create();
$this->pinecone = new PineconeClient( $this->settings );
}
/**
* Create/update Pinecone vectors for a post payload.
*
* @param string $postTitle Post title.
* @param int $postId Post ID.
* @param string $textBody Post content (text).
* @return array{processed:int, vectors:int}
*
* @since 1.0.0
*/
public function upsertDocumentationVectors( string $title, string $link, string $content ): array {
try {
// Clean and prepare text.
$content = wp_strip_all_tags( $content );
$content = trim( $content );
$content = $this->sanitize_utf8( $content );
// Sanitize inputs
$link = $this->sanitize_utf8( $link );
$title = $this->sanitize_utf8( $title );
$id = base64_encode( $title );
if ( empty( $content ) ) {
return [
'processed' => 0,
'vectors' => 0,
];
}
// Chunk the text.
$chunks = $this->chunkText( $content );
// Generate embeddings and prepare vectors.
$vectors = [];
$expected_dims = $this->settings->get( 'vectorDBProviders.pinecone.embeddingDimensions' );
foreach ( $chunks as $index => $chunk ) {
// Sanitize chunk
$chunk = $this->sanitize_utf8( $chunk );
// Generate embedding for this chunk.
$embedding = $this->embeddings->embedText( $chunk );
if ( is_int( $expected_dims ) && $expected_dims > 0 ) {
$actual_dims = is_array( $embedding ) ? count( $embedding ) : 0;
if ( $actual_dims !== $expected_dims ) {
Log::error(
'Embedding dimension mismatch',
[
'expected' => $expected_dims,
'actual' => $actual_dims,
]
);
throw new \Exception( "Embedding dimension {$actual_dims} does not match configured dimension {$expected_dims}. Update settings or index to match." );
}
}
$vectors[] = [
'id' => $id,
'values' => $embedding,
'metadata' => [
'chunk' => $index,
'link' => $this->sanitize_utf8( $link ),
'title' => $this->sanitize_utf8( $title ),
'content' => $this->sanitize_utf8( substr( $chunk, 0, 500 ) ),
],
];
}
// Upsert to Pinecone.
$this->pinecone->upsert( $vectors );
return [
'processed' => 1,
'vectors' => count( $vectors ),
];
} catch ( \Exception $e ) {
Log::error(
"Failed to upsert vectors for link {$id}: {$link}",
[
'error' => $e->getMessage(),
]
);
throw $e;
}
}
/**
* Chunk text into manageable pieces
*
* @param string $text Text to chunk.
* @param int $chunk_size Approximate chunk size in characters.
* @param int $overlap Overlap percentage (0-100).
* @return array Array of text chunks.
*
* @since 1.0.0
*/
private function chunkText( string $text, int $chunk_size = 3500, int $overlap = 10 ): array {
$chunks = [];
$text_length = strlen( $text );
if ( $text_length === 0 ) {
return $chunks;
}
$overlap_size = max( 0, (int) ( $chunk_size * $overlap / 100 ) );
$start = 0;
$max_loops = 10000; // hard safety cap to avoid infinite loops
$loops = 0;
while ( $start < $text_length ) {
++$loops;
if ( $loops > $max_loops ) {
// Safety break
break;
}
$end = min( $start + $chunk_size, $text_length );
$chunk = substr( $text, $start, $end - $start );
$chunk = trim( $chunk );
if ( $chunk !== '' ) {
$chunks[] = $chunk;
}
// If we've reached the end, break to avoid non-advancing start
if ( $end >= $text_length ) {
break;
}
$next_start = $end - $overlap_size;
if ( $next_start <= $start ) {
// Ensure forward progress
$next_start = $end;
}
$start = $next_start;
}
return $chunks;
}
/**
* GET request with proper UTF-8 encoding
*
* @param string $url URL to fetch
* @return string Response body
*
* @since 1.0.0
*/
public function http_get_raw( $url ) {
$res = wp_remote_get( $url );
if ( is_wp_error( $res ) ) {
return '';
}
$body = wp_remote_retrieve_body( $res );
// Convert to UTF-8 if needed
if ( ! mb_check_encoding( $body, 'UTF-8' ) ) {
// Try to detect encoding and convert to UTF-8
$encoding = mb_detect_encoding( $body, [ 'UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII' ], true );
if ( $encoding && $encoding !== 'UTF-8' ) {
$body = mb_convert_encoding( $body, 'UTF-8', $encoding );
} else {
// Fallback: force UTF-8 conversion
$body = mb_convert_encoding( $body, 'UTF-8', 'UTF-8' );
}
}
// Remove any remaining invalid UTF-8 characters
$body = $this->sanitize_utf8( $body );
return $body;
}
/**
* Extract links from the BetterDocs container.
*
* @param string $home_url Home URL
* @return array Array of links
*
* @since 1.0.0
*/
public function get_betterdocs_links( $home_url ) {
$html = $this->http_get_raw( $home_url );
libxml_use_internal_errors( true );
$dom = new DOMDocument();
@$dom->loadHTML( $html );
libxml_clear_errors();
$xpath = new DOMXPath( $dom );
// Extract ALL
$linkNodes = $xpath->query( '//a[@href]' );
$rawLinks = [];
foreach ( $linkNodes as $node ) {
$href = trim( $node->getAttribute( 'href' ) );
if ( ! $href ) {
continue;
}
$rawLinks[] = $href;
}
// Convert relative → absolute
$finalLinks = [];
$baseParsed = wp_parse_url( $home_url );
$base = $baseParsed['scheme'] . '://' . $baseParsed['host'];
foreach ( $rawLinks as $href ) {
// Skip anchors/mailto/javascript
if ( strpos( $href, '#' ) === 0 ) {
continue;
}
if ( strpos( $href, 'mailto:' ) === 0 ) {
continue;
}
if ( strpos( $href, 'javascript:' ) === 0 ) {
continue;
}
// Convert relative URL → absolute
if ( strpos( $href, 'http' ) !== 0 ) {
if ( 0 !== strpos( $href, '/' ) ) {
$href = '/' . ltrim( $href, '/' );
}
$href = $base . $href;
}
// Filter only the required documentation URLs
if ( strpos( $href, 'https://support.themewinter.com/docs/plugins/plugin-docs/' ) === 0 ) {
$finalLinks[] = $href;
}
}
$finalLinks = array_unique( $finalLinks );
return array_values( $finalLinks );
}
/**
* Sanitize UTF-8 text - remove invalid characters
*
* @param string $text Text to sanitize
* @return string Sanitized text
*
* @since 1.0.0
*/
private function sanitize_utf8( $text ) {
if ( empty( $text ) ) {
return $text;
}
// Remove invalid UTF-8 characters
$text = mb_convert_encoding( $text, 'UTF-8', 'UTF-8' );
// Additional cleanup: remove null bytes and other problematic characters
$text = str_replace( "\0", '', $text );
// Remove any remaining non-UTF-8 sequences
if ( function_exists( 'iconv' ) ) {
$text = iconv( 'UTF-8', 'UTF-8//IGNORE', $text );
}
return $text;
}
/**
* Clean HTML into text
*
* @param string $html HTML content
* @return string Cleaned text
*
* @since 1.0.0
*/
public function clean_text( $html ) {
$text = wp_strip_all_tags( $html );
// clear extra white space
$text = trim( preg_replace( '/\s+/', ' ', $text ) );
// Ensure valid UTF-8
$text = $this->sanitize_utf8( $text );
return $text;
}
/**
* Extract main BetterDocs article content using DOM + XPath
*
* @param string $html HTML content
* @return string Article content
*
* @since 1.0.0
*/
public function extract_betterdocs_content( $html ) {
libxml_use_internal_errors( true ); // avoid unintentional error may come, while working with dom and html
$dom = new DOMDocument();
// Add UTF-8 meta tag to ensure proper encoding
$html = '' . $html;
// Load HTML with UTF-8 encoding
@$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$xpath = new DOMXPath( $dom );
$selectors = [
"//div[contains(@id, 'betterdocs-single-content')]",
];
$contentHtml = '';
foreach ( $selectors as $query ) {
$nodes = $xpath->query( $query );
if ( $nodes && $nodes->length > 0 ) {
$contentHtml = $dom->saveHTML( $nodes->item( 0 ) );
break;
}
}
// Fallback: use entire HTML if no content block found
if ( empty( $contentHtml ) ) {
$contentHtml = $html;
}
return $this->clean_text( $contentHtml );
}
/**
* Extract main BetterDocs article title using DOM + XPath
*
* @param string $html HTML content
* @return string Article title
*
* @since 1.0.0
*/
public function extract_betterdocs_title( $html ) {
libxml_use_internal_errors( true );
$dom = new DOMDocument();
// Add UTF-8 meta tag to ensure proper encoding
$html = '' . $html;
// Load HTML with UTF-8 encoding
@$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
libxml_clear_errors();
$xpath = new DOMXPath( $dom );
$selectors = [
'//title',
];
$title = '';
foreach ( $selectors as $query ) {
$nodes = $xpath->query( $query );
if ( $nodes && $nodes->length > 0 ) {
$title = $dom->saveHTML( $nodes->item( 0 ) );
break;
}
}
// Fallback: use entire HTML if no title block found
if ( empty( $title ) ) {
$title = $html;
}
return $this->clean_text( $title );
}
/**
* Sync documentation: Delete all vectors and re-ingest documentation
*
* @return array{deleted:bool, processed:int, vectors:int, errors:array}
*
* @since 1.0.0
*/
public function syncDocumentation(): array {
$DOC_HOME = 'https://support.themewinter.com/docs/plugins/plugin-docs/eventin/introductions-of-eventin/';
$processed = 0;
$total_vectors = 0;
$errors = [];
try {
// deleting previous data from vector db before sync
$this->pinecone->deleteAll();
// Extract all documentation link from home page
$links = $this->get_betterdocs_links( $DOC_HOME );
// iterate over each link to get title and content
foreach ( $links as $index => $link ) {
if ( $index > 80 ) {
break;
}
$html = $this->http_get_raw( $link );
$content = $this->extract_betterdocs_content( $html );
$title = $this->extract_betterdocs_title( $html );
if ( ! $content ) {
continue;
}
$result = $this->upsertDocumentationVectors( $title, $link, $content );
++$processed;
$total_vectors += $result['vectors'] ?? 0;
}
return [
'deleted' => true,
'processed' => $processed,
'vectors' => $total_vectors,
'errors' => $errors,
];
} catch ( \Exception $e ) {
Log::error(
'Vector sync failed',
[
'error' => $e->getMessage(),
]
);
throw $e;
}
}
}