89b3f5844e
Root cause of the Shira hallucination incident in production case 46 Friedman: DocumentAnalyzer::extractFromDocx relied on PhpOffice\PhpWord, but on the EspoCRM prod image the PHPWord files are present in vendor/ yet are NOT registered in the composer PSR-4 autoload map — class_exists() silently returned false, the method returned null, and Shira's read_document fell back to OCR. OCR then only saw the signature image, the AI got "[signature]" as document content and fabricated the entire CTS appeal as a knee injury. This change rewrites extractFromDocx to use ZipArchive + a small regex parser of word/document.xml. Independent of PHPWord, more robust on tables/footnotes/hyperlinks (which PHPWord's element walker missed at depth >1), and verified on prod against the same Friedman appeal: 80 paragraphs / 16633 clean chars extracted (vs 0 before). The paired Python fix in shira-hermes (commit 7b517e1) makes the OCR fallback also read document.xml, so even if this PHP fix regresses again, the AI will not be fed "[signature]" as document content. Refs Task Master #5 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
581 lines
22 KiB
PHP
581 lines
22 KiB
PHP
<?php
|
|
|
|
namespace Espo\Modules\SmartAssistant\Services;
|
|
|
|
use Espo\Core\Exceptions\Error;
|
|
use Espo\Core\InjectableFactory;
|
|
use Espo\Core\Utils\Log;
|
|
use Espo\ORM\EntityManager;
|
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
|
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
|
|
|
class DocumentAnalyzer
|
|
{
|
|
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
|
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
private const TMP_DIR = 'data/tmp/assistant-docs';
|
|
|
|
private EntityManager $entityManager;
|
|
private InjectableFactory $injectableFactory;
|
|
private Log $log;
|
|
private ?int $maxTextLength = null;
|
|
|
|
private const CATEGORY_PATTERNS = [
|
|
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
|
'החלטות_בית_משפט' => ['החלטה', 'פסק_דין', 'פסק דין', 'גזר_דין', 'גזר דין', 'צו', 'פרוטוקול', 'פס"ד', 'גז"ד'],
|
|
'חוזים' => ['חוזה', 'הסכם', 'נספח', 'תוספת'],
|
|
'התכתבויות' => ['מכתב', 'פקס', 'fax', 'letter', 'דואר'],
|
|
'מסמכי_זיהוי' => ['ת.ז.', 'ת"ז', 'תעודת_זהות', 'תעודת זהות', 'דרכון', 'רישיון', 'passport', 'תעודה'],
|
|
'ייפויי_כוח' => ['ייפוי כוח', 'ייפוי_כוח', 'יפוי כח', 'יפוי_כח'],
|
|
'חוות_דעת' => ['חוות_דעת', 'חוות דעת', 'חו"ד', 'חוד'],
|
|
'מסמכים_מסיוע_משפטי' => ['סיוע משפטי', 'סיוע_משפטי', 'לשכת סיוע'],
|
|
'מסמכים_מהביטוח_הלאומי' => ['ביטוח לאומי', 'ביטוח_לאומי', 'בל"ל', 'המוסד לביטוח'],
|
|
];
|
|
|
|
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
|
|
{
|
|
$this->entityManager = $entityManager;
|
|
$this->injectableFactory = $injectableFactory;
|
|
$this->log = $log;
|
|
}
|
|
|
|
public function listDocumentsRecursive(string $caseId): array
|
|
{
|
|
$description = $this->describeCaseFolderPath($caseId);
|
|
$casePath = $description['path'];
|
|
|
|
if (!$casePath) {
|
|
return [
|
|
'casePath' => null,
|
|
'totalFiles' => 0,
|
|
'folders' => [],
|
|
'rootFiles' => [],
|
|
'resolvedPath' => null,
|
|
'pathSource' => $description['source'],
|
|
'folderExists' => false,
|
|
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
|
|
];
|
|
}
|
|
|
|
$client = $this->getStorageClient();
|
|
|
|
try {
|
|
$folderExists = $client->exists($casePath);
|
|
} catch (\Exception $e) {
|
|
return [
|
|
'casePath' => $casePath,
|
|
'totalFiles' => 0,
|
|
'folders' => [],
|
|
'rootFiles' => [],
|
|
'resolvedPath' => $casePath,
|
|
'pathSource' => $description['source'],
|
|
'folderExists' => false,
|
|
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
if (!$folderExists) {
|
|
return [
|
|
'casePath' => $casePath,
|
|
'totalFiles' => 0,
|
|
'folders' => [],
|
|
'rootFiles' => [],
|
|
'resolvedPath' => $casePath,
|
|
'pathSource' => $description['source'],
|
|
'folderExists' => false,
|
|
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
|
|
];
|
|
}
|
|
|
|
try {
|
|
$topLevel = $client->listFolder($casePath);
|
|
} catch (\Exception $e) {
|
|
return [
|
|
'casePath' => $casePath,
|
|
'totalFiles' => 0,
|
|
'folders' => [],
|
|
'rootFiles' => [],
|
|
'resolvedPath' => $casePath,
|
|
'pathSource' => $description['source'],
|
|
'folderExists' => true,
|
|
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
$folders = []; $rootFiles = []; $totalFiles = 0;
|
|
|
|
foreach ($topLevel as $item) {
|
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
|
if ($isFolder) {
|
|
$subFiles = [];
|
|
try {
|
|
foreach ($client->listFolder($item['path']) as $sub) {
|
|
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
|
if (!$subIsFolder) {
|
|
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
|
|
$totalFiles++;
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->log->warning("SmartAssistant: Failed to list subfolder {$item['path']}: " . $e->getMessage());
|
|
}
|
|
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
|
} else {
|
|
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
|
|
$totalFiles++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'casePath' => $casePath,
|
|
'totalFiles' => $totalFiles,
|
|
'folders' => $folders,
|
|
'rootFiles' => $rootFiles,
|
|
'resolvedPath' => $casePath,
|
|
'pathSource' => $description['source'],
|
|
'folderExists' => true,
|
|
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
|
|
];
|
|
}
|
|
|
|
public function extractTextContent(string $filePath): ?string
|
|
{
|
|
$maxLen = $this->getMaxTextLength();
|
|
$text = $this->extractFullText($filePath, $maxLen);
|
|
return $text;
|
|
}
|
|
|
|
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
|
|
{
|
|
$client = $this->getStorageClient();
|
|
$filename = basename($filePath);
|
|
|
|
try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
|
|
|
|
if (strlen($content) > self::MAX_FILE_SIZE) return null;
|
|
|
|
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
|
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
|
file_put_contents($tmpFile, $content);
|
|
|
|
$text = null;
|
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
|
|
try {
|
|
if ($ext === 'pdf') $text = $this->extractFromPdf($tmpFile);
|
|
elseif (in_array($ext, ['docx', 'doc'])) $text = $this->extractFromDocx($tmpFile);
|
|
elseif (in_array($ext, ['txt', 'csv', 'log'])) $text = $content;
|
|
} catch (\Exception $e) {
|
|
$this->log->warning("SmartAssistant: Text extraction failed: " . $e->getMessage());
|
|
}
|
|
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
if ($text === null) return null;
|
|
|
|
$text = trim($text);
|
|
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
|
|
$text = mb_substr($text, 0, $maxLength) . '...';
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Fetch raw file bytes + metadata for OCR/vision fallback.
|
|
*
|
|
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
|
|
*/
|
|
public function getDocumentBytes(string $filePath): array
|
|
{
|
|
$fileName = basename($filePath);
|
|
$client = $this->getStorageClient();
|
|
|
|
try {
|
|
$content = $client->downloadFile($filePath);
|
|
} catch (\Exception $e) {
|
|
return [
|
|
'success' => false,
|
|
'fileName' => $fileName,
|
|
'mimeType' => '',
|
|
'sizeBytes' => 0,
|
|
'base64' => null,
|
|
'error' => 'Failed to download: ' . $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
$sizeBytes = strlen($content);
|
|
if ($sizeBytes > self::MAX_FILE_SIZE) {
|
|
return [
|
|
'success' => false,
|
|
'fileName' => $fileName,
|
|
'mimeType' => '',
|
|
'sizeBytes' => $sizeBytes,
|
|
'base64' => null,
|
|
'error' => 'File too large (max 10MB).',
|
|
];
|
|
}
|
|
|
|
$mimeType = $this->guessMimeType($fileName, $content);
|
|
|
|
return [
|
|
'success' => true,
|
|
'fileName' => $fileName,
|
|
'mimeType' => $mimeType,
|
|
'sizeBytes' => $sizeBytes,
|
|
'base64' => base64_encode($content),
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
private function guessMimeType(string $fileName, string $content): string
|
|
{
|
|
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
|
$map = [
|
|
'pdf' => 'application/pdf',
|
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'doc' => 'application/msword',
|
|
'txt' => 'text/plain',
|
|
'csv' => 'text/csv',
|
|
'log' => 'text/plain',
|
|
'png' => 'image/png',
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'gif' => 'image/gif',
|
|
'bmp' => 'image/bmp',
|
|
'tiff' => 'image/tiff',
|
|
'tif' => 'image/tiff',
|
|
'webp' => 'image/webp',
|
|
];
|
|
if (isset($map[$ext])) {
|
|
return $map[$ext];
|
|
}
|
|
// Fallback: finfo
|
|
if (function_exists('finfo_buffer')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime = finfo_buffer($finfo, $content);
|
|
finfo_close($finfo);
|
|
if ($mime) return $mime;
|
|
}
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($filePaths as $filePath) {
|
|
$fileName = basename($filePath);
|
|
try {
|
|
$text = $this->extractFullText($filePath, $maxPerFile);
|
|
$results[] = [
|
|
'path' => $filePath,
|
|
'fileName' => $fileName,
|
|
'text' => $text,
|
|
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
|
];
|
|
} catch (\Exception $e) {
|
|
$results[] = [
|
|
'path' => $filePath,
|
|
'fileName' => $fileName,
|
|
'text' => null,
|
|
'charCount' => 0,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
public function renameDocument(string $sourcePath, string $newName): array
|
|
{
|
|
$client = $this->getStorageClient();
|
|
$parentPath = dirname($sourcePath);
|
|
$oldName = basename($sourcePath);
|
|
|
|
$oldExt = pathinfo($oldName, PATHINFO_EXTENSION);
|
|
$newExt = pathinfo($newName, PATHINFO_EXTENSION);
|
|
if ($oldExt && (!$newExt || mb_strtolower($newExt) !== mb_strtolower($oldExt))) {
|
|
$newName = pathinfo($newName, PATHINFO_FILENAME) . '.' . $oldExt;
|
|
}
|
|
|
|
$destination = $parentPath . '/' . $newName;
|
|
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
|
|
|
$client->move($sourcePath, $destination);
|
|
$this->updateDocumentEntity($sourcePath, $destination);
|
|
|
|
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
|
}
|
|
|
|
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
|
|
{
|
|
$casePath = $this->getCaseFolderPath($caseId);
|
|
if (!$casePath) {
|
|
throw new Error('Case does not have a document folder configured (networkStorageFolderPath).');
|
|
}
|
|
|
|
$client = $this->getStorageClient();
|
|
|
|
$targetDir = $casePath;
|
|
if ($subfolder) {
|
|
$targetDir = $casePath . '/' . trim($subfolder, '/');
|
|
if (!$client->exists($targetDir)) {
|
|
$client->createFolderRecursive($targetDir);
|
|
}
|
|
}
|
|
|
|
$targetPath = $targetDir . '/' . $fileName;
|
|
$client->uploadFile($targetPath, $content);
|
|
|
|
return [
|
|
'path' => $targetPath,
|
|
'fileName' => $fileName,
|
|
'folder' => $subfolder,
|
|
];
|
|
}
|
|
|
|
public function getCaseFolderPath(string $caseId): ?string
|
|
{
|
|
return $this->describeCaseFolderPath($caseId)['path'];
|
|
}
|
|
|
|
/**
|
|
* Resolve the case folder path with diagnostic metadata.
|
|
*
|
|
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
|
* source: 'stored' | 'computed' | 'none'
|
|
*/
|
|
public function describeCaseFolderPath(string $caseId): array
|
|
{
|
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
|
if (!$case) {
|
|
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
|
|
}
|
|
|
|
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
|
if ($storedPath) {
|
|
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
|
|
}
|
|
|
|
try {
|
|
$nds = $this->getNetworkDocumentService();
|
|
$computed = $nds->getEntityFolderPath('Case', $caseId);
|
|
if ($computed) {
|
|
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
|
|
}
|
|
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
|
|
} catch (\Exception $e) {
|
|
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
|
|
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List the immediate contents of an arbitrary folder path on network storage.
|
|
* Manual override for when auto-resolution of a case folder fails.
|
|
*
|
|
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
|
|
*/
|
|
public function browseFolder(string $path): array
|
|
{
|
|
$client = $this->getStorageClient();
|
|
$entries = [];
|
|
foreach ($client->listFolder($path) as $item) {
|
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
|
$entries[] = [
|
|
'name' => $item['name'] ?? '',
|
|
'path' => $item['path'] ?? '',
|
|
'type' => $isFolder ? 'folder' : 'file',
|
|
'size' => $item['size'] ?? null,
|
|
'mimeType' => $item['mimeType'] ?? null,
|
|
'modified' => $item['modified'] ?? null,
|
|
];
|
|
}
|
|
return $entries;
|
|
}
|
|
|
|
/**
|
|
* Search the storage for folders matching a query (case-insensitive substring).
|
|
* Scans root and one level deep. Returns sorted matches.
|
|
*
|
|
* @return array<int, array{path: string, name: string, level: int, score: int}>
|
|
*/
|
|
public function findCaseFolder(string $query, int $limit = 20): array
|
|
{
|
|
$normalized = trim($query);
|
|
if ($normalized === '') return [];
|
|
|
|
$client = $this->getStorageClient();
|
|
$needle = mb_strtolower($normalized);
|
|
$matches = [];
|
|
|
|
try {
|
|
$roots = $client->listFolder('');
|
|
} catch (\Exception $e) {
|
|
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
|
|
foreach ($roots as $item) {
|
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
|
if (!$isFolder) continue;
|
|
|
|
$name = (string) ($item['name'] ?? '');
|
|
$path = (string) ($item['path'] ?? $name);
|
|
|
|
$score = $this->fuzzyScore($name, $needle);
|
|
if ($score > 0) {
|
|
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
|
|
}
|
|
|
|
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
|
|
try {
|
|
foreach ($client->listFolder($path) as $sub) {
|
|
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
|
if (!$subIsFolder) continue;
|
|
$subName = (string) ($sub['name'] ?? '');
|
|
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
|
|
$subScore = $this->fuzzyScore($subName, $needle);
|
|
if ($subScore > 0) {
|
|
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Non-fatal: skip unreadable subfolders silently.
|
|
}
|
|
}
|
|
|
|
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
|
|
return array_slice($matches, 0, $limit);
|
|
}
|
|
|
|
/**
|
|
* Persist the resolved folder path on the Case so future calls skip discovery.
|
|
*/
|
|
public function setCaseFolderPath(string $caseId, string $path): array
|
|
{
|
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
|
if (!$case) {
|
|
throw new Error("Case '{$caseId}' not found.");
|
|
}
|
|
|
|
$client = $this->getStorageClient();
|
|
if (!$client->exists($path)) {
|
|
throw new Error("Path '{$path}' does not exist on storage.");
|
|
}
|
|
|
|
$case->set('networkStorageFolderPath', $path);
|
|
$this->entityManager->saveEntity($case);
|
|
|
|
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
|
|
}
|
|
|
|
private function fuzzyScore(string $haystack, string $needleLower): int
|
|
{
|
|
$hay = mb_strtolower($haystack);
|
|
if ($hay === $needleLower) return 100;
|
|
if (mb_strpos($hay, $needleLower) !== false) return 80;
|
|
|
|
// Token overlap (whitespace/dash/underscore).
|
|
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
|
|
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
|
|
$hits = 0;
|
|
foreach ($needleTokens as $nt) {
|
|
if ($nt === '') continue;
|
|
foreach ($hayTokens as $ht) {
|
|
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
|
|
}
|
|
}
|
|
return $hits > 0 ? 40 + $hits * 5 : 0;
|
|
}
|
|
|
|
private function getMaxTextLength(): int
|
|
{
|
|
if ($this->maxTextLength !== null) {
|
|
return $this->maxTextLength;
|
|
}
|
|
|
|
try {
|
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
|
if ($integration && $integration->get('enabled')) {
|
|
$data = (array) ($integration->get('data') ?? []);
|
|
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
|
|
return $this->maxTextLength;
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore
|
|
}
|
|
|
|
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
|
|
return $this->maxTextLength;
|
|
}
|
|
|
|
private function getStorageClient(): StorageClientInterface
|
|
{
|
|
return $this->getNetworkDocumentService()->getClient();
|
|
}
|
|
|
|
private function getNetworkDocumentService(): NetworkDocumentService
|
|
{
|
|
return $this->injectableFactory->create(NetworkDocumentService::class);
|
|
}
|
|
|
|
private function extractFromPdf(string $tmpFile): ?string
|
|
{
|
|
$outputFile = $tmpFile . '.txt';
|
|
exec("pdftotext -layout " . escapeshellarg($tmpFile) . " " . escapeshellarg($outputFile) . " 2>&1", $output, $returnCode);
|
|
if ($returnCode !== 0 || !file_exists($outputFile)) return null;
|
|
$text = file_get_contents($outputFile);
|
|
unlink($outputFile);
|
|
return $text ?: null;
|
|
}
|
|
|
|
private function extractFromDocx(string $tmpFile): ?string
|
|
{
|
|
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
|
|
// is NOT in the composer PSR-4 autoload map — class_exists silently
|
|
// returns false. Rather than fix the autoloader (rebuild of the image)
|
|
// we parse word/document.xml directly. This is also more robust:
|
|
// PHPWord's element walker only goes one level deep, missing text in
|
|
// tables, hyperlinks, and footnotes.
|
|
$zip = new \ZipArchive();
|
|
if ($zip->open($tmpFile) !== true) {
|
|
$this->log->warning("SmartAssistant: extractFromDocx: not a valid zip: $tmpFile");
|
|
return null;
|
|
}
|
|
$xml = $zip->getFromName('word/document.xml');
|
|
$zip->close();
|
|
if ($xml === false || $xml === '') {
|
|
$this->log->warning("SmartAssistant: extractFromDocx: word/document.xml missing in $tmpFile");
|
|
return null;
|
|
}
|
|
|
|
// Each <w:p> is a paragraph; concatenate <w:t> runs inside it.
|
|
// `(?:\s[^>]*)?` keeps us from also matching <w:tab> / <w:tbl>.
|
|
$paragraphs = [];
|
|
if (preg_match_all('#<w:p(?:\s[^>]*)?>(.*?)</w:p>#s', $xml, $pMatches)) {
|
|
foreach ($pMatches[1] as $block) {
|
|
if (preg_match_all('#<w:t(?:\s[^>]*)?>(.*?)</w:t>#s', $block, $tMatches)) {
|
|
$line = trim(implode('', $tMatches[1]));
|
|
if ($line !== '') $paragraphs[] = html_entity_decode($line, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|
}
|
|
}
|
|
}
|
|
$text = trim(implode("\n", $paragraphs));
|
|
return $text !== '' ? $text : null;
|
|
}
|
|
|
|
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
|
{
|
|
try {
|
|
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
|
|
if ($doc) {
|
|
$doc->set('networkStoragePath', $newPath);
|
|
$doc->set('networkStorageFileName', basename($newPath));
|
|
$this->entityManager->saveEntity($doc);
|
|
}
|
|
} catch (\Exception $e) {}
|
|
}
|
|
}
|