Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bc4b6770 |
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
|
||||||
|
|
||||||
use Espo\Core\Exceptions\Error;
|
|
||||||
use Espo\Core\InjectableFactory;
|
|
||||||
use Espo\Core\Utils\Config;
|
|
||||||
use Espo\Core\Utils\Log;
|
|
||||||
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
|
|
||||||
|
|
||||||
class FileStorageFactory
|
|
||||||
{
|
|
||||||
private Config $config;
|
|
||||||
private InjectableFactory $injectableFactory;
|
|
||||||
private Log $log;
|
|
||||||
|
|
||||||
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
|
|
||||||
{
|
|
||||||
$this->config = $config;
|
|
||||||
$this->injectableFactory = $injectableFactory;
|
|
||||||
$this->log = $log;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(?string $userId = null): FileStorageInterface
|
|
||||||
{
|
|
||||||
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
|
|
||||||
|
|
||||||
return match ($storageType) {
|
|
||||||
'nextcloud' => $this->createNextCloud($userId),
|
|
||||||
'webdav' => $this->createWebDav(),
|
|
||||||
default => throw new Error("Unknown file storage type: {$storageType}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createNextCloud(?string $userId): NextCloudFileStorage
|
|
||||||
{
|
|
||||||
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
|
|
||||||
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createWebDav(): WebDavFileStorage
|
|
||||||
{
|
|
||||||
$url = $this->config->get('assistantWebDavUrl');
|
|
||||||
$user = $this->config->get('assistantWebDavUser');
|
|
||||||
$password = $this->config->get('assistantWebDavPassword');
|
|
||||||
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
|
|
||||||
|
|
||||||
if (empty($url) || empty($user) || empty($password)) {
|
|
||||||
throw new Error('WebDAV file storage is not configured.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WebDavFileStorage($url, $user, $password, $basePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
|
||||||
|
|
||||||
interface FileStorageInterface
|
|
||||||
{
|
|
||||||
public function listFolder(string $path): array;
|
|
||||||
public function downloadFile(string $path): string;
|
|
||||||
public function move(string $source, string $destination): bool;
|
|
||||||
public function createFolder(string $path): bool;
|
|
||||||
public function exists(string $path): bool;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
|
||||||
|
|
||||||
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
|
|
||||||
|
|
||||||
class NextCloudFileStorage implements FileStorageInterface
|
|
||||||
{
|
|
||||||
private Client $client;
|
|
||||||
|
|
||||||
public function __construct(Client $client)
|
|
||||||
{
|
|
||||||
$this->client = $client;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listFolder(string $path): array { return $this->client->listFolder($path); }
|
|
||||||
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
|
|
||||||
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
|
|
||||||
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
|
|
||||||
public function exists(string $path): bool { return $this->client->exists($path); }
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
|
||||||
|
|
||||||
use Espo\Core\Exceptions\Error;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic WebDAV file storage adapter.
|
|
||||||
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
|
|
||||||
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
|
|
||||||
*/
|
|
||||||
class WebDavFileStorage implements FileStorageInterface
|
|
||||||
{
|
|
||||||
private string $baseUrl;
|
|
||||||
private string $username;
|
|
||||||
private string $password;
|
|
||||||
private string $webdavPath;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
string $baseUrl,
|
|
||||||
string $username,
|
|
||||||
string $password,
|
|
||||||
string $webdavPath = '/webdav'
|
|
||||||
) {
|
|
||||||
$this->baseUrl = rtrim($baseUrl, '/');
|
|
||||||
$this->username = $username;
|
|
||||||
$this->password = $password;
|
|
||||||
$this->webdavPath = $webdavPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listFolder(string $path): array
|
|
||||||
{
|
|
||||||
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<d:propfind xmlns:d="DAV:">
|
|
||||||
<d:prop>
|
|
||||||
<d:getlastmodified/>
|
|
||||||
<d:getetag/>
|
|
||||||
<d:getcontenttype/>
|
|
||||||
<d:getcontentlength/>
|
|
||||||
<d:resourcetype/>
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>';
|
|
||||||
|
|
||||||
$response = $this->request('PROPFIND', $path, $propfindXml, [
|
|
||||||
'Content-Type: application/xml',
|
|
||||||
'Depth: 1',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response['status'] !== 207) {
|
|
||||||
throw new Error("Failed to list folder: HTTP " . $response['status']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->parsePropfindResponse($response['body'], $path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function downloadFile(string $path): string
|
|
||||||
{
|
|
||||||
$response = $this->request('GET', $path);
|
|
||||||
|
|
||||||
if ($response['status'] !== 200) {
|
|
||||||
throw new Error("Failed to download file: HTTP " . $response['status']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response['body'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function move(string $source, string $destination): bool
|
|
||||||
{
|
|
||||||
$destUrl = $this->buildUrl($destination);
|
|
||||||
|
|
||||||
$response = $this->request('MOVE', $source, null, [
|
|
||||||
'Destination: ' . $destUrl,
|
|
||||||
'Overwrite: F',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response['status'] !== 201 && $response['status'] !== 204) {
|
|
||||||
throw new Error("Failed to move: HTTP " . $response['status']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createFolder(string $path): bool
|
|
||||||
{
|
|
||||||
$parts = array_filter(explode('/', $path));
|
|
||||||
$currentPath = '';
|
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
$currentPath .= '/' . $part;
|
|
||||||
$response = $this->request('MKCOL', $currentPath);
|
|
||||||
|
|
||||||
// 201 = created, 405 = already exists
|
|
||||||
if ($response['status'] !== 201 && $response['status'] !== 405) {
|
|
||||||
throw new Error("Failed to create folder: HTTP " . $response['status']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exists(string $path): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
|
|
||||||
return $response['status'] === 207;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildUrl(string $path): string
|
|
||||||
{
|
|
||||||
$pathParts = explode('/', ltrim($path, '/'));
|
|
||||||
$encodedParts = array_map('rawurlencode', $pathParts);
|
|
||||||
|
|
||||||
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{status: int, body: string}
|
|
||||||
*/
|
|
||||||
private function request(
|
|
||||||
string $method,
|
|
||||||
string $path,
|
|
||||||
?string $body = null,
|
|
||||||
array $headers = []
|
|
||||||
): array {
|
|
||||||
$url = $this->buildUrl($path);
|
|
||||||
|
|
||||||
$ch = curl_init();
|
|
||||||
|
|
||||||
$allHeaders = array_merge([
|
|
||||||
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
|
|
||||||
], $headers);
|
|
||||||
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_URL => $url,
|
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_HTTPHEADER => $allHeaders,
|
|
||||||
CURLOPT_TIMEOUT => 120,
|
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($body !== null) {
|
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
||||||
}
|
|
||||||
|
|
||||||
$responseBody = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$curlError = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($curlError) {
|
|
||||||
throw new Error("WebDAV connection error: $curlError");
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => $httpCode,
|
|
||||||
'body' => $responseBody,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parsePropfindResponse(string $xml, string $basePath): array
|
|
||||||
{
|
|
||||||
$items = [];
|
|
||||||
$normalizedBasePath = '/' . trim($basePath, '/');
|
|
||||||
|
|
||||||
$doc = new \DOMDocument();
|
|
||||||
$doc->loadXML($xml);
|
|
||||||
|
|
||||||
$xpath = new \DOMXPath($doc);
|
|
||||||
$xpath->registerNamespace('d', 'DAV:');
|
|
||||||
|
|
||||||
$responses = $xpath->query('//d:response');
|
|
||||||
|
|
||||||
foreach ($responses as $response) {
|
|
||||||
$hrefNodes = $xpath->query('d:href', $response);
|
|
||||||
if ($hrefNodes->length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$href = urldecode($hrefNodes->item(0)->textContent);
|
|
||||||
|
|
||||||
// Extract relative path by removing webdav prefix
|
|
||||||
$relativePath = $href;
|
|
||||||
$webdavPrefix = $this->webdavPath . '/';
|
|
||||||
if (($pos = strpos($href, $webdavPrefix)) !== false) {
|
|
||||||
$relativePath = substr($href, $pos + strlen($webdavPrefix));
|
|
||||||
}
|
|
||||||
$relativePath = '/' . trim($relativePath, '/');
|
|
||||||
|
|
||||||
// Skip the queried folder itself
|
|
||||||
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
|
|
||||||
if (!$propstat) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
|
|
||||||
$isFolder = $resourceType->length > 0;
|
|
||||||
|
|
||||||
$contentLength = $xpath->query('d:getcontentlength', $propstat);
|
|
||||||
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
|
|
||||||
|
|
||||||
$lastModified = $xpath->query('d:getlastmodified', $propstat);
|
|
||||||
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
|
|
||||||
|
|
||||||
$contentType = $xpath->query('d:getcontenttype', $propstat);
|
|
||||||
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
|
|
||||||
|
|
||||||
$items[] = [
|
|
||||||
'name' => basename($relativePath),
|
|
||||||
'path' => $relativePath,
|
|
||||||
'isFolder' => $isFolder,
|
|
||||||
'size' => $size,
|
|
||||||
'mimeType' => $isFolder ? 'folder' : $mimeType,
|
|
||||||
'modified' => $modified,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($items, function ($a, $b) {
|
|
||||||
if ($a['isFolder'] !== $b['isFolder']) {
|
|
||||||
return $b['isFolder'] <=> $a['isFolder'];
|
|
||||||
}
|
|
||||||
return strcasecmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,6 +42,9 @@ class ActionExecutor
|
|||||||
'list_documents' => $this->listDocuments($caseId),
|
'list_documents' => $this->listDocuments($caseId),
|
||||||
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
||||||
'rename_document' => $this->renameDocument($params),
|
'rename_document' => $this->renameDocument($params),
|
||||||
|
'upload_document' => $this->uploadDocument($params, $caseId),
|
||||||
|
'generate_document' => $this->generateDocument($params, $caseId),
|
||||||
|
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
||||||
'save_memory' => $this->saveMemory($params, $caseId, $userId),
|
'save_memory' => $this->saveMemory($params, $caseId, $userId),
|
||||||
default => throw new Error("Unknown tool: {$tool}"),
|
default => throw new Error("Unknown tool: {$tool}"),
|
||||||
};
|
};
|
||||||
@@ -243,6 +246,99 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function uploadDocument(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
if (!$caseId) {
|
||||||
|
throw new Error('upload_document requires a case context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = $params['fileName'] ?? null;
|
||||||
|
$content = $params['content'] ?? null;
|
||||||
|
if (!$fileName) throw new Error('fileName parameter is required.');
|
||||||
|
if (!$content) throw new Error('content parameter is required (base64-encoded file data).');
|
||||||
|
|
||||||
|
$decoded = base64_decode($content, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw new Error('content is not valid base64.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null);
|
||||||
|
|
||||||
|
$msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה";
|
||||||
|
if (!empty($params['folder'])) {
|
||||||
|
$msg .= " לתיקייה \"{$params['folder']}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => $msg, 'data' => $result];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateDocument(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
if (!$caseId) {
|
||||||
|
throw new Error('generate_document requires a case context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateId = $params['templateId'] ?? null;
|
||||||
|
if (!$templateId) throw new Error('templateId parameter is required.');
|
||||||
|
|
||||||
|
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||||
|
if (!$template) throw new NotFound("Template {$templateId} not found.");
|
||||||
|
|
||||||
|
$documentSubject = $params['documentSubject'] ?? null;
|
||||||
|
|
||||||
|
$templateService = $this->injectableFactory->create(
|
||||||
|
\Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject);
|
||||||
|
|
||||||
|
if (empty($result['success'])) {
|
||||||
|
throw new Error('Failed to generate document from template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateName = $template->get('name') ?? 'document';
|
||||||
|
$fileName = $result['fileName'] ?? "{$templateName}.docx";
|
||||||
|
|
||||||
|
$msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\"";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $msg,
|
||||||
|
'data' => ['fileName' => $fileName],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergeDocuments(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
if (!$caseId) {
|
||||||
|
throw new Error('merge_documents requires a case context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentIds = $params['documentIds'] ?? [];
|
||||||
|
if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).');
|
||||||
|
|
||||||
|
$documents = [];
|
||||||
|
foreach ($documentIds as $docId) {
|
||||||
|
$doc = $this->entityManager->getEntityById('Document', $docId);
|
||||||
|
if (!$doc) throw new NotFound("Document {$docId} not found.");
|
||||||
|
$documents[] = $doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergerService = $this->injectableFactory->create(
|
||||||
|
\Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = $params['options'] ?? [];
|
||||||
|
$mergedPath = $mergerService->mergeWithLabels($documents, $options);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "✅ " . count($documents) . " מסמכים מוזגו בהצלחה",
|
||||||
|
'data' => ['path' => $mergedPath, 'documentCount' => count($documents)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeDateTime(?string $value): ?string
|
private function normalizeDateTime(?string $value): ?string
|
||||||
{
|
{
|
||||||
if ($value === null || $value === '') return null;
|
if ($value === null || $value === '') return null;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use Espo\Core\Exceptions\Error;
|
|||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\Core\Utils\Log;
|
use Espo\Core\Utils\Log;
|
||||||
use Espo\ORM\EntityManager;
|
use Espo\ORM\EntityManager;
|
||||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
|
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
||||||
|
|
||||||
class DocumentAnalyzer
|
class DocumentAnalyzer
|
||||||
{
|
{
|
||||||
@@ -43,17 +43,19 @@ class DocumentAnalyzer
|
|||||||
$casePath = $this->getCaseFolderPath($caseId);
|
$casePath = $this->getCaseFolderPath($caseId);
|
||||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
||||||
|
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$topLevel = $storage->listFolder($casePath);
|
$topLevel = $client->listFolder($casePath);
|
||||||
$folders = []; $rootFiles = []; $totalFiles = 0;
|
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||||
|
|
||||||
foreach ($topLevel as $item) {
|
foreach ($topLevel as $item) {
|
||||||
if ($item['isFolder']) {
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||||
|
if ($isFolder) {
|
||||||
$subFiles = [];
|
$subFiles = [];
|
||||||
try {
|
try {
|
||||||
foreach ($storage->listFolder($item['path']) as $sub) {
|
foreach ($client->listFolder($item['path']) as $sub) {
|
||||||
if (!$sub['isFolder']) {
|
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||||
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null];
|
if (!$subIsFolder) {
|
||||||
|
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
|
||||||
$totalFiles++;
|
$totalFiles++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
||||||
} else {
|
} else {
|
||||||
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'], 'mimeType' => $item['mimeType'], 'modified' => $item['modified'] ?? null];
|
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
|
||||||
$totalFiles++;
|
$totalFiles++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,17 +74,12 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
public function extractTextContent(string $filePath): ?string
|
public function extractTextContent(string $filePath): ?string
|
||||||
{
|
{
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$parentPath = dirname($filePath);
|
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
|
|
||||||
try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; }
|
try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
|
||||||
|
|
||||||
$fileSize = null;
|
if (strlen($content) > self::MAX_FILE_SIZE) return null;
|
||||||
foreach ($items as $item) { if ($item['name'] === $filename) { $fileSize = $item['size']; break; } }
|
|
||||||
if ($fileSize !== null && $fileSize > self::MAX_FILE_SIZE) return null;
|
|
||||||
|
|
||||||
try { $content = $storage->downloadFile($filePath); } catch (\Exception $e) { return null; }
|
|
||||||
|
|
||||||
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
||||||
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
||||||
@@ -107,7 +104,7 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
public function renameDocument(string $sourcePath, string $newName): array
|
public function renameDocument(string $sourcePath, string $newName): array
|
||||||
{
|
{
|
||||||
$storage = $this->getStorage();
|
$client = $this->getStorageClient();
|
||||||
$parentPath = dirname($sourcePath);
|
$parentPath = dirname($sourcePath);
|
||||||
$oldName = basename($sourcePath);
|
$oldName = basename($sourcePath);
|
||||||
|
|
||||||
@@ -120,21 +117,66 @@ class DocumentAnalyzer
|
|||||||
$destination = $parentPath . '/' . $newName;
|
$destination = $parentPath . '/' . $newName;
|
||||||
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
||||||
|
|
||||||
$storage->move($sourcePath, $destination);
|
$client->move($sourcePath, $destination);
|
||||||
$this->updateDocumentEntity($sourcePath, $destination);
|
$this->updateDocumentEntity($sourcePath, $destination);
|
||||||
|
|
||||||
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCaseFolderPath(string $caseId): ?string
|
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
|
||||||
{
|
{
|
||||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
$casePath = $this->getCaseFolderPath($caseId);
|
||||||
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null;
|
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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getStorage(): FileStorageInterface
|
public function getCaseFolderPath(string $caseId): ?string
|
||||||
{
|
{
|
||||||
return $this->injectableFactory->create(FileStorageFactory::class)->create();
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) return null;
|
||||||
|
|
||||||
|
// Try stored path first
|
||||||
|
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
||||||
|
if ($storedPath) return $storedPath;
|
||||||
|
|
||||||
|
// Fall back to computed path from NetworkDocumentService
|
||||||
|
try {
|
||||||
|
$nds = $this->getNetworkDocumentService();
|
||||||
|
return $nds->getEntityFolderPath('Case', $caseId);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStorageClient(): StorageClientInterface
|
||||||
|
{
|
||||||
|
return $this->getNetworkDocumentService()->getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getNetworkDocumentService(): NetworkDocumentService
|
||||||
|
{
|
||||||
|
return $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractFromPdf(string $tmpFile): ?string
|
private function extractFromPdf(string $tmpFile): ?string
|
||||||
@@ -168,10 +210,10 @@ class DocumentAnalyzer
|
|||||||
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
|
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
|
||||||
if ($doc) {
|
if ($doc) {
|
||||||
$doc->set('nextCloudPath', $newPath);
|
$doc->set('networkStoragePath', $newPath);
|
||||||
$doc->set('nextCloudFilename', basename($newPath));
|
$doc->set('networkStorageFileName', basename($newPath));
|
||||||
$this->entityManager->saveEntity($doc);
|
$this->entityManager->saveEntity($doc);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {}
|
} catch (\Exception $e) {}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SmartAssistantService
|
|||||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||||
|
|
||||||
// All tools execute inline — Shira has full permissions
|
// All tools execute inline — Shira has full permissions
|
||||||
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'save_memory'];
|
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
|
|||||||
+2
-2
@@ -3,11 +3,11 @@
|
|||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-04-04",
|
"releaseDate": "2026-04-05",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user