import React, { useState, useEffect, useCallback } from 'react'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { PlusIcon, XIcon } from "lucide-react"; import { __, sprintf } from '@wordpress/i18n'; import { preparePdfOcrImages, buildOcrBundle, MAX_OCR_PAGES } from '@/lib/pdfOcrPages'; const AISK_API = (window.AiskSettings?.apiUrl || '/wp-json/aisk/v1').replace(/\/+$/, ''); const PdfProcessing = ({ settings, updateSettings, isActiveConfigTab }) => { const [processMessage, setProcessMessage] = useState(''); // PDF processing states const [openPdfDialog, setOpenPdfDialog] = useState(false); const [pdfProcessing, setPdfProcessing] = useState(false); const [pdfProgress, setPdfProgress] = useState(0); const [pdfFile, setPdfFile] = useState(null); const [pdfError, setPdfError] = useState(null); const [pdfMessage, setPdfMessage] = useState(''); // PDFs from the server's processing queue const [pdfs, setPdfs] = useState([]); // Delete confirmation modal state const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const [pdfToDelete, setPdfToDelete] = useState(null); // Store polling intervals for each PDF const pollingIntervals = React.useRef({}); // Helper function to get max upload size const getMaxUploadSize = () => { if (window.AiskSettings && window.AiskSettings.maxUploadSize) { return (window.AiskSettings.maxUploadSize / (1024 * 1024)).toFixed(1) + ' MB'; } return '2 MB'; // Default fallback }; // Helper function to validate file size const validateFileSize = (file) => { const maxSize = window.AiskSettings?.maxUploadSize || 2 * 1024 * 1024; // Default 2MB if (file.size > maxSize) { setPdfError(sprintf( __('File size exceeds the maximum allowed size of %s. This is limited by your server configuration.', 'aisk-ai-chat'), getMaxUploadSize() )); return false; } return true; }; // Helper function to validate PDF file const validatePdfFile = (file) => { // Check file extension if (!file.name.toLowerCase().endsWith('.pdf')) { setPdfError(__('Please upload a PDF file with .pdf extension', 'aisk-ai-chat')); return false; } // Check MIME type if (file.type && file.type !== 'application/pdf') { setPdfError(__('Invalid file type. Please upload a valid PDF file', 'aisk-ai-chat')); return false; } // Check if file is a valid PDF by checking the header return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { const uint8Array = new Uint8Array(e.target.result); const header = Array.from(uint8Array.slice(0, 5)) .map(byte => String.fromCharCode(byte)) .join(''); if (header === '%PDF-') { resolve(true); } else { setPdfError(__('Invalid PDF file. The file does not appear to be a valid PDF document', 'aisk-ai-chat')); resolve(false); } }; reader.readAsArrayBuffer(file); }); }; const addNewPdf = () => { setPdfFile(null); setPdfError(null); setPdfProgress(0); setPdfMessage(''); setOpenPdfDialog(true); }; const processPdf = async () => { if (!pdfFile) { setPdfError(__('No PDF file selected', 'aisk-ai-chat')); return; } // Validate file size before processing if (!validateFileSize(pdfFile)) { return; } // Set processing state immediately - this will affect button text/state setPdfProcessing(true); setPdfError(null); // Image-only PDFs (scans, designs with outlined text) have no text layer. // Render their pages here so the server can read them with OCR. let ocrBundle = null; try { setPdfMessage(__('Checking PDF text...', 'aisk-ai-chat')); const ocr = await preparePdfOcrImages(pdfFile, (done, total) => { setPdfMessage(sprintf( /* translators: 1: rendered page number, 2: total pages */ __('No text layer found. Preparing page %1$d of %2$d for OCR...', 'aisk-ai-chat'), done, total )); }); if (ocr.imageOnly) { ocrBundle = buildOcrBundle(ocr.tiles); const maxSize = window.AiskSettings?.maxUploadSize || 2 * 1024 * 1024; if (pdfFile.size + ocrBundle.size > maxSize) { setPdfError(sprintf( /* translators: %s: maximum upload size */ __('This PDF contains only images, and its pages are too large to upload for OCR (limit %s). Split it into smaller PDFs and try again.', 'aisk-ai-chat'), getMaxUploadSize() )); setPdfMessage(''); setPdfProcessing(false); return; } } setPdfMessage(''); } catch (ocrError) { setPdfMessage(''); if (ocrError.message === 'too_many_pages') { setPdfError(sprintf( /* translators: 1: number of pages in the PDF, 2: maximum pages */ __('This PDF contains only images (%1$d pages). OCR supports up to %2$d pages; split it into smaller PDFs and try again.', 'aisk-ai-chat'), ocrError.pages, MAX_OCR_PAGES )); setPdfProcessing(false); return; } console.error('PDF OCR preparation failed:', ocrError); setPdfError(ocrError.message === 'timeout' ? __('Reading the PDF in your browser took too long. Please try again.', 'aisk-ai-chat') : sprintf( /* translators: %s: error message */ __('Could not read this PDF in your browser: %s', 'aisk-ai-chat'), ocrError.message || String(ocrError) )); setPdfProcessing(false); return; } try { // Close the dialog and show progress in the main UI setOpenPdfDialog(false); setPdfProgress(10); setProcessMessage(__('Uploading PDF...', 'aisk-ai-chat')); const formData = new FormData(); formData.append('pdf_file', pdfFile); if (ocrBundle) { formData.append('ocr_pages', ocrBundle, 'ocr-pages.json'); } const response = await fetch(`${AISK_API}/process-pdf`, { method: 'POST', headers: { 'X-WP-Nonce': window.AiskData.nonce }, body: formData }); setPdfProgress(60); let result; const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { result = await response.json(); } else { const text = await response.text(); console.error('Invalid response:', text); throw new Error(__('Server returned an invalid response. Please try again.', 'aisk-ai-chat')); } if (!response.ok || !result.success) { throw new Error(result.message || __('Failed to process PDF', 'aisk-ai-chat')); } setPdfProgress(100); setProcessMessage(ocrBundle ? __('PDF uploaded. Reading its pages with OCR, this can take a minute...', 'aisk-ai-chat') : __('PDF uploaded and queued for processing.', 'aisk-ai-chat')); setPdfFile(null); await loadPdfs(); } catch (error) { console.error('Error processing PDF:', error); setProcessMessage(error.message || __('Failed to process PDF. Please try again.', 'aisk-ai-chat')); } finally { setPdfProcessing(false); setTimeout(() => setPdfProgress(0), 1000); } }; // The server's PDF queue is the source of truth for this list const loadPdfs = useCallback(async () => { try { const response = await fetch(`${AISK_API}/pdf-queue-list`, { headers: { 'X-WP-Nonce': window.AiskData.nonce } }); if (!response.ok) { return; } const list = await response.json(); setPdfs(Array.isArray(list) ? list : []); } catch (error) { console.error('Error loading PDFs:', error); } }, []); useEffect(() => { loadPdfs(); }, [loadPdfs]); // While a PDF is queued or processing, refresh the list every 5 seconds const hasPendingPdf = pdfs.some(pdf => pdf.status === 'pending' || pdf.status === 'processing'); useEffect(() => { if (!hasPendingPdf) { return undefined; } const timer = setInterval(loadPdfs, 5000); return () => clearInterval(timer); }, [hasPendingPdf, loadPdfs]); // Announce the result when a PDF finishes processing const previousStatuses = React.useRef({}); useEffect(() => { pdfs.forEach(pdf => { const before = previousStatuses.current[pdf.job_id]; if (before && before !== pdf.status) { if (pdf.status === 'completed') { /* translators: %s: PDF file name */ setProcessMessage(sprintf(__('%s processed successfully!', 'aisk-ai-chat'), pdf.name)); } else if (pdf.status === 'failed') { /* translators: 1: PDF file name, 2: error message */ setProcessMessage(sprintf(__('Failed to process %1$s: %2$s', 'aisk-ai-chat'), pdf.name, pdf.error_message || '')); } } previousStatuses.current[pdf.job_id] = pdf.status; }); }, [pdfs]); const confirmDeletePdf = (index) => { setPdfToDelete(index); setOpenDeleteDialog(true); }; // Cancel PDF deletion const cancelDelete = () => { setPdfToDelete(null); setOpenDeleteDialog(false); }; // Delete the PDF, its embeddings and its queue job on the server const confirmDelete = async () => { const pdf = pdfToDelete !== null ? pdfs[pdfToDelete] : null; setOpenDeleteDialog(false); setPdfToDelete(null); if (!pdf) { return; } setProcessMessage(__('Deleting PDF...', 'aisk-ai-chat')); try { const response = await fetch(`${AISK_API}/delete-pdf`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.AiskData.nonce }, body: JSON.stringify({ attachment_id: pdf.attachment_id, job_id: pdf.job_id }) }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.success) { throw new Error(data.message || sprintf( /* translators: %d: HTTP status code */ __('Server returned %d', 'aisk-ai-chat'), response.status )); } setPdfs(prev => prev.filter(item => item.job_id !== pdf.job_id)); setProcessMessage(__('PDF deleted successfully.', 'aisk-ai-chat')); } catch (error) { console.error('Error removing PDF:', error); /* translators: %s: error message */ setProcessMessage(sprintf(__('Error deleting PDF: %s', 'aisk-ai-chat'), error.message)); } finally { loadPdfs(); setTimeout(() => setProcessMessage(''), 4000); } }; return (
{/* Status message */} {processMessage && (
{processMessage}
)} {/* Progress bar */} {pdfProcessing && (

{__('Processing... This may take a few minutes for larger files.', 'aisk-ai-chat')}

)} {pdfs.length === 0 ? (

{__('No PDF sources added yet', 'aisk-ai-chat')}

) : (
{pdfs.map((pdfConfig, index) => (
{pdfConfig.name || __('Unnamed PDF', 'aisk-ai-chat')} {/* Status badge with color and error message for failed */} {pdfConfig.status && ( <> {({ pending: __('Queued', 'aisk-ai-chat'), processing: __('Processing', 'aisk-ai-chat'), completed: __('Processed', 'aisk-ai-chat'), failed: __('Failed', 'aisk-ai-chat') })[pdfConfig.status] || pdfConfig.status} {pdfConfig.status === 'failed' && pdfConfig.error_message && ( {pdfConfig.error_message} )} )} {pdfConfig.status === 'completed' && pdfConfig.chunks > 0 && ( {pdfConfig.chunks} {__('chunks', 'aisk-ai-chat')} )} {pdfConfig.size && ( {(Number(pdfConfig.size) / (1024 * 1024)).toFixed(2)} MB )} {/* Show error message within the row for failed PDFs */} {pdfConfig.status === 'failed' && pdfConfig.user_message && ( {__('Error:', 'aisk-ai-chat')} {pdfConfig.user_message} )}
))}
)} {/* PDF Upload Dialog */} {__('Add PDF Document', 'aisk-ai-chat')} {__('Upload a PDF document to process and include in the knowledge base', 'aisk-ai-chat')}
{ const file = e.target.files[0]; if (file) { // Check file size on client side // const maxSize = window.AiskSettings?.maxUploadSize || 2 * 1024 * 1024; // Default 2MB const maxSize = getMaxUploadSize(); if (file.size > maxSize) { setPdfError( __(`The file is too large. Maximum allowed size is ${getMaxUploadSize()}. This is limited by your server configuration.`, 'aisk-ai-chat') ); e.target.value = ''; // Clear the input } else { // Validate PDF file validatePdfFile(file).then((isValid) => { if (isValid) { setPdfFile(file); setPdfError(null); // Clear any previous errors } else { e.target.value = ''; // Clear the input } }); } } }} />

{__(`Maximum file size: ${getMaxUploadSize()} (server limit)`, 'aisk-ai-chat')}

{pdfFile && (

{__('Selected file:', 'aisk-ai-chat')} {pdfFile.name} ({(pdfFile.size / (1024 * 1024)).toFixed(2)} MB)

)}
{pdfError && (

{pdfError}

)} {pdfMessage && !pdfError && (

{pdfMessage}

)}
{/* Delete Confirmation Dialog */} {__('Confirm Deletion', 'aisk-ai-chat')} {__('Are you sure you want to delete this PDF? This will remove it from your knowledge base.', 'aisk-ai-chat')} {pdfToDelete !== null && pdfs[pdfToDelete] && (

{pdfs[pdfToDelete].name || __('Unnamed PDF', 'aisk-ai-chat')}

)}
); }; export default PdfProcessing;