Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1476d5c52 | |||
| 68b3536084 | |||
| 97f7607b2b |
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"controller": "controllers/record",
|
||||
"color": "#5e8c61",
|
||||
"iconClass": "fas fa-list-check",
|
||||
"boolFilterList": ["onlyMy"],
|
||||
"filterList": [
|
||||
{"name": "active"},
|
||||
{"name": "inactive"}
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"controller": "controllers/record",
|
||||
"color": "#9b59b6",
|
||||
"iconClass": "fas fa-brain",
|
||||
"boolFilterList": ["onlyMy"],
|
||||
"filterList": [
|
||||
{"name": "pinned"},
|
||||
{"name": "byCategory"}
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -7,5 +7,7 @@
|
||||
"aclLevelList": ["all", "team", "own", "no"],
|
||||
"stream": false,
|
||||
"tab": true,
|
||||
"disabled": false
|
||||
"disabled": false,
|
||||
"importable": false,
|
||||
"customizable": true
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||
|
||||
/**
|
||||
* Wraps NetworkDocumentService and constrains every operation to the case's
|
||||
@@ -34,14 +35,32 @@ class CaseFolderManager
|
||||
*/
|
||||
public function writeFile(string $caseId, string $relPath, string $content): array
|
||||
{
|
||||
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
$parent = $this->dirnameRel($absInsideStorage);
|
||||
if ($parent !== '') {
|
||||
$client->createFolderRecursive($parent);
|
||||
// Split the (LLM-provided) relPath into folder + filename. Subfolders
|
||||
// are allowed, but the final filename is sanitized and de-duplicated by
|
||||
// the canonical namer (rule N1) — never trust the model to name files.
|
||||
$normalized = $this->normalize($relPath);
|
||||
if ($normalized === '') {
|
||||
throw new BadRequest('Path is required.');
|
||||
}
|
||||
|
||||
$slash = strrpos($normalized, '/');
|
||||
$dirRel = $slash === false ? '' : substr($normalized, 0, $slash);
|
||||
$baseName = $slash === false ? $normalized : substr($normalized, $slash + 1);
|
||||
|
||||
$ext = pathinfo($baseName, PATHINFO_EXTENSION) ?: 'bin';
|
||||
$subject = pathinfo($baseName, PATHINFO_FILENAME);
|
||||
|
||||
$parentAbs = $dirRel === ''
|
||||
? $this->getCaseRoot($caseId)
|
||||
: $this->resolveSafePath($caseId, $this->sanitizeRelSegments($dirRel), requireExists: false);
|
||||
|
||||
$client->createFolderRecursive($parentAbs);
|
||||
|
||||
$storedName = LocalFilesystemClient::buildStoredFileName($client, $parentAbs, $subject, $ext);
|
||||
$absInsideStorage = $parentAbs . '/' . $storedName;
|
||||
|
||||
$ok = $client->uploadFile($absInsideStorage, $content);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to write file: {$absInsideStorage}");
|
||||
@@ -61,7 +80,12 @@ class CaseFolderManager
|
||||
*/
|
||||
public function createFolder(string $caseId, string $relPath): array
|
||||
{
|
||||
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||
$cleanRel = $this->sanitizeRelSegments($this->normalize($relPath));
|
||||
if ($cleanRel === '') {
|
||||
throw new BadRequest('A valid folder name is required.');
|
||||
}
|
||||
|
||||
$abs = $this->resolveSafePath($caseId, $cleanRel, requireExists: false);
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
$alreadyExisted = $client->exists($abs);
|
||||
@@ -91,6 +115,12 @@ class CaseFolderManager
|
||||
throw new BadRequest('newName must be a plain file/folder name without slashes.');
|
||||
}
|
||||
|
||||
// Sanitize the model-chosen name (rule N1).
|
||||
$newName = LocalFilesystemClient::sanitizeFileName($newName);
|
||||
if ($newName === '') {
|
||||
throw new BadRequest('newName is empty after sanitization.');
|
||||
}
|
||||
|
||||
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
|
||||
|
||||
$parent = $this->dirnameRel($absCurrent);
|
||||
@@ -124,7 +154,11 @@ class CaseFolderManager
|
||||
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
|
||||
{
|
||||
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
|
||||
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
|
||||
$absTarget = $this->resolveSafePath(
|
||||
$caseId,
|
||||
$this->sanitizeRelSegments($this->normalize($targetRelPath)),
|
||||
requireExists: false
|
||||
);
|
||||
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
@@ -297,6 +331,26 @@ class CaseFolderManager
|
||||
return implode('/', $out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize every segment of an already-normalized storage-relative path
|
||||
* with the canonical file-name rules (rule N1). Empty segments are dropped.
|
||||
*/
|
||||
private function sanitizeRelSegments(string $normalizedPath): string
|
||||
{
|
||||
if ($normalizedPath === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$segments = array_map(
|
||||
static fn (string $seg): string => LocalFilesystemClient::sanitizeFileName($seg),
|
||||
explode('/', $normalizedPath)
|
||||
);
|
||||
|
||||
$segments = array_filter($segments, static fn (string $seg): bool => $seg !== '');
|
||||
|
||||
return implode('/', $segments);
|
||||
}
|
||||
|
||||
private function dirnameRel(string $path): string
|
||||
{
|
||||
$pos = strrpos($path, '/');
|
||||
|
||||
@@ -9,6 +9,7 @@ use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||
use PhpOffice\PhpWord\PhpWord;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
||||
@@ -50,9 +51,16 @@ class FreeDocumentGenerator
|
||||
|
||||
$docxBinary = $this->buildDocx($title, $body, $recipient);
|
||||
|
||||
$sanitizedTitle = $this->sanitizeFileName($title);
|
||||
$today = date('Y-m-d');
|
||||
$fileName = "{$today} - {$sanitizedTitle}.docx";
|
||||
// Subject-only naming (rule N1): no date prefix. The canonical stored
|
||||
// name (+ "-{NNN}" on collision) is produced by NetworkDocumentService
|
||||
// on upload; we then align the Espo Document/Attachment to it so the
|
||||
// CRM, the attachment and the file on disk never disagree.
|
||||
$baseName = LocalFilesystemClient::sanitizeFileName($title);
|
||||
if ($baseName === '') {
|
||||
$baseName = 'document';
|
||||
}
|
||||
$fileName = $baseName . '.docx';
|
||||
$documentName = $title;
|
||||
|
||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||
$attachment->set([
|
||||
@@ -67,12 +75,15 @@ class FreeDocumentGenerator
|
||||
|
||||
$document = $this->entityManager->getNewEntity('Document');
|
||||
$document->set([
|
||||
'name' => $title,
|
||||
'name' => $documentName,
|
||||
'fileId' => $attachment->getId(),
|
||||
'fileName' => $fileName,
|
||||
'assignedUserId' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document);
|
||||
// Suppress the NSI upload hook: it fires on this save, before the Case
|
||||
// link below exists, so it would file the document in the fallback
|
||||
// folder. We upload explicitly (with the Case) right after.
|
||||
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
|
||||
|
||||
$this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'documents')
|
||||
@@ -83,7 +94,39 @@ class FreeDocumentGenerator
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
if ($nds->isEnabled()) {
|
||||
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
|
||||
|
||||
if (!empty($result['success'])) {
|
||||
$networkPath = $result['path'] ?? null;
|
||||
$storedName = $result['fileName'] ?? $fileName;
|
||||
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $documentName;
|
||||
|
||||
// Align DB names to the canonical stored name.
|
||||
$document->set([
|
||||
'name' => $storedBase,
|
||||
'fileName' => $storedName,
|
||||
'storageType' => 'network',
|
||||
'networkStoragePath' => $networkPath,
|
||||
'networkStorageFileName' => $storedName,
|
||||
'networkStorageSize' => $result['size'] ?? strlen($docxBinary),
|
||||
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document, [
|
||||
'skipNetworkUpload' => true,
|
||||
'silent' => true,
|
||||
]);
|
||||
|
||||
$attachment->set('name', $storedName);
|
||||
$this->entityManager->saveEntity($attachment, ['silent' => true]);
|
||||
|
||||
// The file now lives in network storage; drop the local copy.
|
||||
$sourcePath = 'data/upload/' . $attachment->getSourceId();
|
||||
if (file_exists($sourcePath)) {
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
|
||||
$fileName = $storedName;
|
||||
$documentName = $storedBase;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Document is safely persisted in Espo; the network-storage copy is best-effort.
|
||||
@@ -94,10 +137,10 @@ class FreeDocumentGenerator
|
||||
}
|
||||
|
||||
$this->log->info(
|
||||
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
|
||||
"FreeDocumentGenerator: Created '{$documentName}' (type={$documentType}) for Case {$caseId}"
|
||||
);
|
||||
|
||||
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
|
||||
$message = "✅ המסמך \"{$documentName}\" נוצר בהצלחה וצורף לתיק";
|
||||
if ($networkPath) {
|
||||
$message .= " ולתיקיית הרשת";
|
||||
}
|
||||
@@ -106,7 +149,7 @@ class FreeDocumentGenerator
|
||||
return [
|
||||
'success' => true,
|
||||
'documentId' => $document->getId(),
|
||||
'documentName' => $title,
|
||||
'documentName' => $documentName,
|
||||
'fileName' => $fileName,
|
||||
'networkPath' => $networkPath,
|
||||
'message' => $message,
|
||||
@@ -265,11 +308,4 @@ class FreeDocumentGenerator
|
||||
$year = $dt->format('Y');
|
||||
return "{$day} ב{$month} {$year}";
|
||||
}
|
||||
|
||||
private function sanitizeFileName(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
|
||||
$name = preg_replace('/\s+/', ' ', $name);
|
||||
return trim(mb_substr($name, 0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||
use PhpOffice\PhpWord\TemplateProcessor;
|
||||
|
||||
class GenericTemplateGenerator
|
||||
@@ -72,14 +73,23 @@ class GenericTemplateGenerator
|
||||
@unlink($outputPath);
|
||||
@unlink($localTemplatePath);
|
||||
|
||||
// Step 6: Build document name
|
||||
$contactName = $this->getContactName($case);
|
||||
$docName = $documentSubject ?? "{$templateName} — {$contactName}";
|
||||
// Step 6: Build subject-only document name (rule N1) — no contact,
|
||||
// no em-dash. Defaults to the template name when no subject given.
|
||||
$subject = trim((string) ($documentSubject ?? ''));
|
||||
if ($subject === '') {
|
||||
$subject = (string) $templateName;
|
||||
}
|
||||
$docName = $subject;
|
||||
$baseName = LocalFilesystemClient::sanitizeFileName($subject);
|
||||
if ($baseName === '') {
|
||||
$baseName = 'document';
|
||||
}
|
||||
$fileName = $baseName . '.docx';
|
||||
|
||||
// Step 7: Create Attachment + Document
|
||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||
$attachment->set([
|
||||
'name' => $docName . '.docx',
|
||||
'name' => $fileName,
|
||||
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'role' => 'Attachment',
|
||||
'size' => strlen($content),
|
||||
@@ -92,16 +102,61 @@ class GenericTemplateGenerator
|
||||
$document->set([
|
||||
'name' => $docName,
|
||||
'fileId' => $attachment->getId(),
|
||||
'fileName' => $docName . '.docx',
|
||||
'fileName' => $fileName,
|
||||
'assignedUserId' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document);
|
||||
// Suppress the NSI upload hook (fires before the Case link below);
|
||||
// we upload explicitly with the Case so it lands in the case folder.
|
||||
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
|
||||
|
||||
// Link to Case
|
||||
$this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
// Upload to the case folder; align DB names to the canonical stored name.
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
if ($nds->isEnabled()) {
|
||||
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
|
||||
|
||||
if (!empty($result['success'])) {
|
||||
$storedName = $result['fileName'] ?? $fileName;
|
||||
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $docName;
|
||||
|
||||
$document->set([
|
||||
'name' => $storedBase,
|
||||
'fileName' => $storedName,
|
||||
'storageType' => 'network',
|
||||
'networkStoragePath' => $result['path'] ?? null,
|
||||
'networkStorageFileName' => $storedName,
|
||||
'networkStorageSize' => $result['size'] ?? strlen($content),
|
||||
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document, [
|
||||
'skipNetworkUpload' => true,
|
||||
'silent' => true,
|
||||
]);
|
||||
|
||||
$attachment->set('name', $storedName);
|
||||
$this->entityManager->saveEntity($attachment, ['silent' => true]);
|
||||
|
||||
$sourcePath = 'data/upload/' . $attachment->getSourceId();
|
||||
if (file_exists($sourcePath)) {
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
|
||||
$docName = $storedBase;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Document is safely persisted in Espo; the network copy is best-effort.
|
||||
$this->log->warning(
|
||||
"GenericTemplateGenerator: failed to copy to network storage for Case {$caseId}: " .
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||
|
||||
return [
|
||||
@@ -190,24 +245,4 @@ class GenericTemplateGenerator
|
||||
|
||||
$processor->saveAs($outputPath);
|
||||
}
|
||||
|
||||
private function getContactName($case): string
|
||||
{
|
||||
$contactId = $case->get('contactId');
|
||||
if (!$contactId) {
|
||||
$ids = $case->getLinkMultipleIdList('contacts');
|
||||
if (!empty($ids)) {
|
||||
$contactId = $ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($contactId) {
|
||||
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
||||
if ($contact) {
|
||||
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
|
||||
}
|
||||
}
|
||||
|
||||
return $case->get('name') ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "2.10.2",
|
||||
"version": "2.10.5",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-05-27",
|
||||
"releaseDate": "2026-06-03",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user