feat: add attach from documents
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
+207
-45
@@ -5,21 +5,29 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\Mail;
|
namespace KTXM\Mail;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Module\ModuleManager;
|
||||||
use KTXF\Mail\Object\Address;
|
use KTXF\Mail\Object\Address;
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
use KTXF\Mail\Object\MessagePart;
|
use KTXF\Mail\Object\MessagePart;
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
use KTXF\Resource\Identifier\ResourceIdentifier;
|
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||||
use KTXF\Utile\UUID;
|
use KTXF\Utile\UUID;
|
||||||
use KTXM\Mail\Stores\CompositionStore;
|
use KTXM\Mail\Stores\CompositionStore;
|
||||||
use KTXM\MailManager\Manager as MailManager;
|
use KTXM\MailManager\Manager as MailManager;
|
||||||
|
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
class CompositionManager {
|
class CompositionManager {
|
||||||
|
|
||||||
|
private const DOCUMENTS_MODULE_HANDLE = 'documents_manager';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly ContainerInterface $container,
|
||||||
|
private readonly ModuleManager $moduleManager,
|
||||||
private readonly CompositionStore $compositionStore,
|
private readonly CompositionStore $compositionStore,
|
||||||
private readonly MailManager $mailManager,
|
private readonly MailManager $mailManager,
|
||||||
) {}
|
) {}
|
||||||
@@ -75,7 +83,7 @@ class CompositionManager {
|
|||||||
data: $data,
|
data: $data,
|
||||||
);
|
);
|
||||||
// supplement the attachment metadata with source information
|
// supplement the attachment metadata with source information
|
||||||
$meta['origin'] = 'source';
|
$meta['origin'] = 'message';
|
||||||
$meta['source'] = $source;
|
$meta['source'] = $source;
|
||||||
$meta['partId'] = $attachment->getId();
|
$meta['partId'] = $attachment->getId();
|
||||||
$meta['blobId'] = $attachment->getBlobId();
|
$meta['blobId'] = $attachment->getBlobId();
|
||||||
@@ -293,39 +301,12 @@ class CompositionManager {
|
|||||||
return $properties;
|
return $properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int,array|string> $entries
|
|
||||||
* @return array<int,Address>
|
|
||||||
*/
|
|
||||||
private function mapAddresses(array $entries): array {
|
|
||||||
$addresses = [];
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if (is_array($entry)) {
|
|
||||||
$address = Address::fromArray($entry);
|
|
||||||
if ($address->getAddress() !== '') {
|
|
||||||
$addresses[] = $address;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($entry) && $entry !== '') {
|
|
||||||
$address = Address::fromString($entry);
|
|
||||||
if ($address->getAddress() !== '') {
|
|
||||||
$addresses[] = $address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
|
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
|
||||||
$result = [
|
$result = [
|
||||||
'composition' => $composition,
|
'composition' => $composition,
|
||||||
'attachments' => [],
|
'attachments' => [],
|
||||||
];
|
];
|
||||||
|
// fetch the composition to ensure it exists
|
||||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
|
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
|
||||||
if ($composed === null) {
|
if ($composed === null) {
|
||||||
$result['disposition'] = 'error';
|
$result['disposition'] = 'error';
|
||||||
@@ -335,32 +316,169 @@ class CompositionManager {
|
|||||||
];
|
];
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
// separate attachments by type
|
||||||
|
$uploads = [];
|
||||||
|
$documents = [];
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
|
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
|
||||||
$identifier = UUID::v4();
|
$documents[] = $attachment;
|
||||||
} else {
|
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
|
||||||
$identifier = $attachment['identifier'];
|
$uploads[] = $attachment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// process uploaded attachments
|
||||||
|
if ($uploads !== []) {
|
||||||
|
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
|
||||||
|
}
|
||||||
|
// process attachments from the documents module
|
||||||
|
if ($documents !== []) {
|
||||||
|
$failed = $documents !== []
|
||||||
|
? $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
// save the updated composition with new attachments
|
||||||
|
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
||||||
|
// construct the result
|
||||||
|
$result['attachments'] = $composed['attachments'];
|
||||||
|
$result['failed'] = $failed;
|
||||||
|
$result['disposition'] = match (true) {
|
||||||
|
$failed === [] => 'added',
|
||||||
|
$result['attachments'] !== [] => 'partial',
|
||||||
|
default => 'error',
|
||||||
|
};
|
||||||
|
if ($result['disposition'] === 'error') {
|
||||||
|
$result['error'] = [
|
||||||
|
'type' => 'attachment_add_failed',
|
||||||
|
'message' => 'None of the requested attachments could be added',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$meta = $this->compositionStore->attachmentStageFromBase64(
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage device-uploaded (base64 payload) attachment entries
|
||||||
|
*/
|
||||||
|
private function attachmentAddFromDevice(string $tenantId, string $userId, string $composition, array $uploads, array &$composed): void {
|
||||||
|
foreach ($uploads as $attachment) {
|
||||||
|
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
|
||||||
|
? $attachment['identifier']
|
||||||
|
: UUID::v4();
|
||||||
|
|
||||||
|
$decoded = base64_decode((string) ($attachment['data'] ?? ''), true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw new InvalidArgumentException('Attachment payload is not valid base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = new BinaryResource(
|
||||||
|
$attachment['name'] ?? 'unknown.bin',
|
||||||
|
$attachment['type'] ?? 'application/octet-stream',
|
||||||
|
$this->stringToGenerator($decoded),
|
||||||
|
);
|
||||||
|
|
||||||
|
$meta = $this->compositionStore->attachmentStageFromStream(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
userId: $userId,
|
userId: $userId,
|
||||||
compositionId: $composition,
|
compositionId: $composition,
|
||||||
attachmentId: $identifier,
|
attachmentId: $identifier,
|
||||||
name: $attachment['name'] ?? 'unknown.bin',
|
data: $data,
|
||||||
type: $attachment['type'] ?? 'application/octet-stream',
|
|
||||||
data: $attachment['data'] ?? '',
|
|
||||||
);
|
);
|
||||||
$meta['origin'] = 'upload';
|
$meta['origin'] = 'device';
|
||||||
|
$composed['attachments'][$meta['identifier']] = $meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDocumentsManager(): ?DocumentsManager {
|
||||||
|
$module = $this->moduleManager->fetch(self::DOCUMENTS_MODULE_HANDLE);
|
||||||
|
if ($module === null || !$module->enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!$this->container->has(DocumentsManager::class)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->container->get(DocumentsManager::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage documents-sourced attachment entries
|
||||||
|
*/
|
||||||
|
private function attachmentAddFromDocuments(string $tenantId, string $userId, string $composition, array $sourced, array &$composed): array {
|
||||||
|
$failed = [];
|
||||||
|
|
||||||
|
// The documents module is an optional dependency, so we must resolve it
|
||||||
|
// lazily and handle the case where it is not installed or enabled.
|
||||||
|
$documentsManager = $this->resolveDocumentsManager();
|
||||||
|
if ($documentsManager === null) {
|
||||||
|
foreach ($sourced as $attachment) {
|
||||||
|
$failed[] = [
|
||||||
|
'identifier' => (string) ($attachment['source'] ?? ''),
|
||||||
|
'message' => 'Documents module is not installed or enabled',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requested = [];
|
||||||
|
foreach ($sourced as $attachment) {
|
||||||
|
$rawIdentifier = (string) $attachment['source'];
|
||||||
|
try {
|
||||||
|
$parsed = ResourceIdentifier::fromString($rawIdentifier);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$parsed instanceof EntityIdentifier) {
|
||||||
|
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$requested[] = ['attachment' => $attachment, 'identifier' => $parsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requested === []) {
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entities = $documentsManager->entityFetchBulk($tenantId, $userId, ...array_column($requested, 'identifier'));
|
||||||
|
$entitiesByUrn = [];
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$entitiesByUrn[$entity->urn()] = $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requested as ['attachment' => $attachment, 'identifier' => $entityIdentifier]) {
|
||||||
|
$urn = (string) $entityIdentifier;
|
||||||
|
$entity = $entitiesByUrn[$urn] ?? null;
|
||||||
|
if ($entity === null) {
|
||||||
|
$failed[] = ['identifier' => $urn, 'message' => 'Document not found or access denied'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = $documentsManager->entityReadStream($tenantId, $userId, $entityIdentifier);
|
||||||
|
if ($resource === null) {
|
||||||
|
$failed[] = ['identifier' => $urn, 'message' => 'Document content could not be read'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
|
||||||
|
? $attachment['identifier']
|
||||||
|
: UUID::v4();
|
||||||
|
|
||||||
|
$properties = $entity->getProperties();
|
||||||
|
$data = new BinaryResource($properties->getLabel(), $properties->getMime(), $this->streamToGenerator($resource));
|
||||||
|
|
||||||
|
$meta = $this->compositionStore->attachmentStageFromStream(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
userId: $userId,
|
||||||
|
compositionId: $composition,
|
||||||
|
attachmentId: $identifier,
|
||||||
|
data: $data,
|
||||||
|
);
|
||||||
|
$meta['origin'] = 'documents';
|
||||||
|
$meta['source'] = $urn;
|
||||||
$composed['attachments'][$meta['identifier']] = $meta;
|
$composed['attachments'][$meta['identifier']] = $meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
return $failed;
|
||||||
|
|
||||||
$result['disposition'] = 'added';
|
|
||||||
$result['attachments'] = $composed['attachments'];
|
|
||||||
return $result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
|
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
|
||||||
@@ -393,4 +511,48 @@ class CompositionManager {
|
|||||||
$result['disposition'] = 'removed';
|
$result['disposition'] = 'removed';
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array|string> $entries
|
||||||
|
* @return array<int,Address>
|
||||||
|
*/
|
||||||
|
private function mapAddresses(array $entries): array {
|
||||||
|
$addresses = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$address = Address::fromArray($entry);
|
||||||
|
if ($address->getAddress() !== '') {
|
||||||
|
$addresses[] = $address;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($entry) && $entry !== '') {
|
||||||
|
$address = Address::fromString($entry);
|
||||||
|
if ($address->getAddress() !== '') {
|
||||||
|
$addresses[] = $address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an in-memory byte string in a single-chunk Generator.
|
||||||
|
*/
|
||||||
|
private function stringToGenerator(string $bytes): \Generator {
|
||||||
|
yield $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
|
||||||
|
*/
|
||||||
|
private function streamToGenerator($resource): \Generator {
|
||||||
|
while (!feof($resource)) {
|
||||||
|
yield fread($resource, 65536);
|
||||||
|
}
|
||||||
|
fclose($resource);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -54,13 +54,7 @@ final class CompositionStore {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachmentStageFromStream(
|
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
|
||||||
string $tenantId,
|
|
||||||
string $userId,
|
|
||||||
string $compositionId,
|
|
||||||
string $attachmentId,
|
|
||||||
BinaryResource $data,
|
|
||||||
): array {
|
|
||||||
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
|
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
|
||||||
if (!is_dir($attachmentDir)) {
|
if (!is_dir($attachmentDir)) {
|
||||||
mkdir($attachmentDir, 0755, true);
|
mkdir($attachmentDir, 0755, true);
|
||||||
@@ -91,40 +85,6 @@ final class CompositionStore {
|
|||||||
return $meta;
|
return $meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachmentStageFromBase64(
|
|
||||||
string $tenantId,
|
|
||||||
string $userId,
|
|
||||||
string $compositionId,
|
|
||||||
string $attachmentId,
|
|
||||||
string $name,
|
|
||||||
string $type,
|
|
||||||
string $data,
|
|
||||||
): array {
|
|
||||||
$decoded = base64_decode($data, true);
|
|
||||||
if ($decoded === false) {
|
|
||||||
throw new \InvalidArgumentException('Attachment payload is not valid base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
|
|
||||||
if (!is_dir($attachmentDir)) {
|
|
||||||
mkdir($attachmentDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($attachmentDir . '/' . $attachmentId . '.blob', $decoded);
|
|
||||||
|
|
||||||
$metadata = [
|
|
||||||
'identifier' => $attachmentId,
|
|
||||||
'composition' => $compositionId,
|
|
||||||
'name' => $name,
|
|
||||||
'type' => $type,
|
|
||||||
'size' => strlen($decoded),
|
|
||||||
];
|
|
||||||
|
|
||||||
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
||||||
|
|
||||||
return $metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
|
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
|
||||||
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
|
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
|
||||||
if (!file_exists($path)) {
|
if (!file_exists($path)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models
|
|||||||
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
|
||||||
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||||
import { ComposerMode } from '@/types/composer'
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import { documentsSelectorComponent } from '@/services/documentsSelector'
|
||||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||||
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||||
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||||
@@ -48,6 +49,8 @@ const {
|
|||||||
const showCc = ref(false)
|
const showCc = ref(false)
|
||||||
const showBcc = ref(false)
|
const showBcc = ref(false)
|
||||||
const applyingDraftToEditor = ref(false)
|
const applyingDraftToEditor = ref(false)
|
||||||
|
const showDocumentSelector = ref(false)
|
||||||
|
const documentSelector = computed(() => documentsSelectorComponent())
|
||||||
|
|
||||||
const sender = computed({
|
const sender = computed({
|
||||||
get: () => activeDraft.value?.sender ?? null,
|
get: () => activeDraft.value?.sender ?? null,
|
||||||
@@ -166,8 +169,23 @@ const handleSend = async () => {
|
|||||||
await compositionStore.sendDraft()
|
await compositionStore.sendDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAttach = async (files: File[]) => {
|
const handleAttachFromDevice = async (files: File[]) => {
|
||||||
await compositionStore.addAttachments(files)
|
await compositionStore.addAttachmentsFromDevice(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttachFromDocuments = () => {
|
||||||
|
showDocumentSelector.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentsSelected = async (items: { kind: 'entity' | 'collection'; identifier: string; name: string; mimeType?: string; size?: number }[]) => {
|
||||||
|
// The dialog defaults to entities-only (no `selectable` prop passed below), but
|
||||||
|
// filter defensively since mail can't attach a folder as a single file blob.
|
||||||
|
const files = items.filter(item => item.kind === 'entity')
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await compositionStore.addAttachmentsFromDocuments(files)
|
||||||
|
showDocumentSelector.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDetach = async (identifier: string) => {
|
const handleDetach = async (identifier: string) => {
|
||||||
@@ -264,9 +282,18 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
@bullet-list="toggleBulletList"
|
@bullet-list="toggleBulletList"
|
||||||
@ordered-list="toggleOrderedList"
|
@ordered-list="toggleOrderedList"
|
||||||
@link="toggleLink"
|
@link="toggleLink"
|
||||||
@attach="handleAttach"
|
@attach-from-device="handleAttachFromDevice"
|
||||||
|
@attach-from-documents="handleAttachFromDocuments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<component
|
||||||
|
:is="documentSelector"
|
||||||
|
v-if="documentSelector"
|
||||||
|
v-model="showDocumentSelector"
|
||||||
|
:selectable="['entity']"
|
||||||
|
@select="handleDocumentsSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||||
|
import { documentsSelectorAvailable } from '@/services/documentsSelector'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
editor: {
|
editor: {
|
||||||
@@ -41,7 +42,8 @@ const emit = defineEmits<{
|
|||||||
bulletList: []
|
bulletList: []
|
||||||
orderedList: []
|
orderedList: []
|
||||||
link: []
|
link: []
|
||||||
attach: [files: File[]]
|
attachFromDevice: [files: File[]]
|
||||||
|
attachFromDocuments: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -54,7 +56,7 @@ function handleFileChange(event: Event) {
|
|||||||
const input = event.target as HTMLInputElement | null
|
const input = event.target as HTMLInputElement | null
|
||||||
const files = input?.files ? Array.from(input.files) : []
|
const files = input?.files ? Array.from(input.files) : []
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
emit('attach', files)
|
emit('attachFromDevice', files)
|
||||||
}
|
}
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = ''
|
input.value = ''
|
||||||
@@ -142,6 +144,11 @@ function handleFileChange(event: Event) {
|
|||||||
<v-icon>mdi-paperclip</v-icon>
|
<v-icon>mdi-paperclip</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn v-if="documentsSelectorAvailable()" icon size="small" @click="$emit('attachFromDocuments')">
|
||||||
|
<v-icon>mdi-folder-search-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Attach from Documents</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* documentsSelector — bridges the Mail composer to documents_manager's
|
||||||
|
* document/collection picker dialog, resolved at runtime via the integration
|
||||||
|
* store. documents_manager (not the end-user `documents` app) registers this,
|
||||||
|
* since it's the module designed to be depended on by other feature modules;
|
||||||
|
* this mirrors CompositionManager's own ModuleManager-based check on the
|
||||||
|
* backend, which also targets documents_manager.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
import { useIntegrationStore } from '@KTXC'
|
||||||
|
|
||||||
|
const DOCUMENTS_SELECTOR_POINT = 'documents_selector'
|
||||||
|
|
||||||
|
/** True if a document selector dialog has been registered (i.e. documents_manager is enabled). */
|
||||||
|
export function documentsSelectorAvailable(): boolean {
|
||||||
|
return useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentsSelectorComponent() {
|
||||||
|
// The integration store prefixes ids with the module handle
|
||||||
|
// (e.g. 'documents_manager.dialog'), so match on the suffix.
|
||||||
|
const entry = useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).find(i => i.id.endsWith('.dialog'))
|
||||||
|
return entry?.component
|
||||||
|
? defineAsyncComponent(entry.component as () => Promise<unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { ComposerMode } from '@/types/composer'
|
import { ComposerMode } from '@/types/composer'
|
||||||
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
|
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
|
||||||
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
|
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
|
||||||
|
import type { EntityIdentifier } from '@MailManager/types/common'
|
||||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||||
|
|
||||||
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
|
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
|
||||||
@@ -274,7 +275,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
queueSave(false)
|
queueSave(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addAttachments(files: File[]) {
|
async function addAttachmentsFromDevice(files: File[]) {
|
||||||
if (!activeDraft.value || files.length === 0) {
|
if (!activeDraft.value || files.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -288,7 +289,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
return [identifier, {
|
return [identifier, {
|
||||||
identifier,
|
identifier,
|
||||||
composition,
|
composition,
|
||||||
origin: 'upload',
|
origin: 'device',
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type || 'application/octet-stream',
|
type: file.type || 'application/octet-stream',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -327,6 +328,66 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
|
||||||
|
if (!activeDraft.value || items.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const composition = activeDraft.value.identifier
|
||||||
|
const attachments = Object.fromEntries(
|
||||||
|
items.map(item => {
|
||||||
|
const identifier = createIdentifier()
|
||||||
|
return [identifier, {
|
||||||
|
identifier,
|
||||||
|
composition,
|
||||||
|
origin: 'documents',
|
||||||
|
name: item.name,
|
||||||
|
type: item.mimeType || 'application/octet-stream',
|
||||||
|
size: item.size ?? 0,
|
||||||
|
source: item.identifier as EntityIdentifier,
|
||||||
|
} satisfies CompositionAttachmentInterface]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: CompositionAttachmentAddRequest = {
|
||||||
|
composition,
|
||||||
|
attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await compositionService.attachmentAdd(request)
|
||||||
|
|
||||||
|
if (!activeDraft.value || response.composition !== activeDraft.value.identifier) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.disposition === 'error') {
|
||||||
|
console.error('[Mail][Composer] Failed to add attachments from documents:', response.error)
|
||||||
|
mailUiStore.notify(`Failed to attach documents: ${response.error?.message || 'Unknown error'}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.values(response.attachments).forEach(attachment => {
|
||||||
|
activeDraft.value!.attachments[attachment.identifier] = {
|
||||||
|
identifier: attachment.identifier,
|
||||||
|
composition: attachment.composition,
|
||||||
|
origin: attachment.origin,
|
||||||
|
name: attachment.name,
|
||||||
|
type: attachment.type,
|
||||||
|
size: attachment.size,
|
||||||
|
source: attachment.source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.failed?.length) {
|
||||||
|
mailUiStore.notify(
|
||||||
|
response.failed.length === 1
|
||||||
|
? '1 document could not be attached'
|
||||||
|
: `${response.failed.length} documents could not be attached`,
|
||||||
|
'warning',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeAttachment(identifier: string) {
|
async function removeAttachment(identifier: string) {
|
||||||
if (!activeDraft.value) {
|
if (!activeDraft.value) {
|
||||||
return
|
return
|
||||||
@@ -424,17 +485,14 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
updateRecipients,
|
updateRecipients,
|
||||||
updateSubject,
|
updateSubject,
|
||||||
updateBody,
|
updateBody,
|
||||||
addAttachments,
|
addAttachmentsFromDevice,
|
||||||
|
addAttachmentsFromDocuments,
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
flushSave,
|
flushSave,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildDraft(
|
function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?: EntityObject | MessageAddressObject | null): ComposerDraft {
|
||||||
mode: ComposerMode,
|
|
||||||
sender: ComposerSenderIdentity,
|
|
||||||
source?: EntityObject | MessageAddressObject | null,
|
|
||||||
): ComposerDraft {
|
|
||||||
const composition = createIdentifier()
|
const composition = createIdentifier()
|
||||||
const freshMessage = buildMessage(mode, source)
|
const freshMessage = buildMessage(mode, source)
|
||||||
const freshAttachments = buildAttachments(composition, mode, source)
|
const freshAttachments = buildAttachments(composition, mode, source)
|
||||||
@@ -452,10 +510,7 @@ function buildDraft(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessage(
|
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined): ComposerDraftMessage {
|
||||||
mode: ComposerMode,
|
|
||||||
source: EntityObject | MessageAddressObject | null | undefined,
|
|
||||||
): ComposerDraftMessage {
|
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return emptyMessage()
|
return emptyMessage()
|
||||||
@@ -521,7 +576,7 @@ function buildAttachments(composition: string, mode: ComposerMode, source?: Enti
|
|||||||
attachments[id] = {
|
attachments[id] = {
|
||||||
identifier: id,
|
identifier: id,
|
||||||
composition: composition,
|
composition: composition,
|
||||||
origin: 'source',
|
origin: 'message',
|
||||||
name: attachment.name || '',
|
name: attachment.name || '',
|
||||||
type: attachment.type || 'application/octet-stream',
|
type: attachment.type || 'application/octet-stream',
|
||||||
size: attachment.size || 0,
|
size: attachment.size || 0,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export enum ComposerMode {
|
|||||||
export interface ComposerDraftAttachment {
|
export interface ComposerDraftAttachment {
|
||||||
identifier: string
|
identifier: string
|
||||||
composition: string
|
composition: string
|
||||||
origin: 'source' | 'upload'
|
origin: 'device' | 'message' | 'documents'
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface CompositionMessageInterface {
|
|||||||
export interface CompositionAttachmentInterface {
|
export interface CompositionAttachmentInterface {
|
||||||
identifier: string
|
identifier: string
|
||||||
composition: string
|
composition: string
|
||||||
origin: 'source' | 'upload'
|
origin: 'device' | 'message' | 'documents'
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
size: number
|
size: number
|
||||||
@@ -94,13 +94,17 @@ export interface CompositionAttachmentAddRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompositionAttachmentAddResponse {
|
export interface CompositionAttachmentAddResponse {
|
||||||
disposition: 'added' | 'error'
|
disposition: 'added' | 'partial' | 'error'
|
||||||
error?: {
|
error?: {
|
||||||
type: string
|
type: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
composition: string
|
composition: string
|
||||||
attachments: Record<string, CompositionAttachmentInterface>
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
|
failed?: {
|
||||||
|
identifier: string
|
||||||
|
message: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompositionAttachmentRemoveRequest {
|
export interface CompositionAttachmentRemoveRequest {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Mail\Tests\Unit;
|
||||||
|
|
||||||
|
use KTXC\Module\ModuleManager;
|
||||||
|
use KTXC\Module\ModuleObject;
|
||||||
|
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||||
|
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
|
||||||
|
use KTXM\Mail\CompositionManager;
|
||||||
|
use KTXM\Mail\Stores\CompositionStore;
|
||||||
|
use PHPUnit\Framework\MockObject\Stub;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
// KTXM\MailManager\Manager and KTXM\DocumentsManager\Manager live in sibling
|
||||||
|
// modules. This module's isolated test bootstrap only autoloads its own PSR-4
|
||||||
|
// map plus the server's KTXC/KTXF namespaces (see tests/php/bootstrap.php), so
|
||||||
|
// sibling-module classes are not otherwise resolvable here. Require them
|
||||||
|
// directly so PHPUnit can reflect on the real classes to build mocks; both
|
||||||
|
// files only depend on already-autoloadable KTXC/KTXF types. Skipped
|
||||||
|
// gracefully in setUp() if this module is ever checked out standalone.
|
||||||
|
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
|
||||||
|
$documentsManagerFile = dirname(__DIR__, 4) . '/documents_manager/lib/Manager.php';
|
||||||
|
if (is_file($mailManagerFile)) {
|
||||||
|
require_once $mailManagerFile;
|
||||||
|
}
|
||||||
|
if (is_file($documentsManagerFile)) {
|
||||||
|
require_once $documentsManagerFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CompositionManagerTest extends TestCase {
|
||||||
|
|
||||||
|
private const TENANT_ID = 'tenant-1';
|
||||||
|
private const USER_ID = 'user-1';
|
||||||
|
|
||||||
|
private string $rootDir;
|
||||||
|
private CompositionStore $compositionStore;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
if (!class_exists(\KTXM\MailManager\Manager::class) || !class_exists(\KTXM\DocumentsManager\Manager::class)) {
|
||||||
|
$this->markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid();
|
||||||
|
mkdir($this->rootDir, 0755, true);
|
||||||
|
$this->compositionStore = new CompositionStore($this->rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
$this->deleteDir($this->rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddFromDocumentsHappyPath(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
||||||
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
||||||
|
]);
|
||||||
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
||||||
|
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('added', $result['disposition']);
|
||||||
|
$this->assertCount(1, $result['attachments']);
|
||||||
|
$this->assertSame([], $result['failed']);
|
||||||
|
|
||||||
|
$attachment = $result['attachments']['attach-1'];
|
||||||
|
$this->assertSame('documents', $attachment['origin']);
|
||||||
|
$this->assertSame('default:personal:root:doc1', $attachment['source']);
|
||||||
|
$this->assertSame('hello.txt', $attachment['name']);
|
||||||
|
$this->assertSame('text/plain', $attachment['type']);
|
||||||
|
$this->assertSame(strlen('hello world'), $attachment['size']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddFromDocumentsPartialFailure(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
// only doc1 is returned even though doc1 and doc2 are both requested
|
||||||
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
||||||
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
||||||
|
]);
|
||||||
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
||||||
|
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
$compositionId,
|
||||||
|
['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
||||||
|
['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('partial', $result['disposition']);
|
||||||
|
$this->assertCount(1, $result['attachments']);
|
||||||
|
$this->assertCount(1, $result['failed']);
|
||||||
|
$this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddMixedUploadAndDocuments(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
||||||
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
||||||
|
]);
|
||||||
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
||||||
|
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(
|
||||||
|
self::TENANT_ID,
|
||||||
|
self::USER_ID,
|
||||||
|
$compositionId,
|
||||||
|
['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')],
|
||||||
|
['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('added', $result['disposition']);
|
||||||
|
$this->assertCount(2, $result['attachments']);
|
||||||
|
$this->assertSame('device', $result['attachments']['attach-upload']['origin']);
|
||||||
|
$this->assertSame('documents', $result['attachments']['attach-doc']['origin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddCompositionNotFound(): void {
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
$manager = $this->makeManager($documentsManager);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('error', $result['disposition']);
|
||||||
|
$this->assertSame('composition_not_found', $result['error']['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttachmentAddDocumentsModuleUnavailable(): void {
|
||||||
|
$compositionId = $this->stageEmptyComposition();
|
||||||
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
||||||
|
// documents_manager is not installed/enabled: attachmentAdd must still
|
||||||
|
// work for the composition itself, just report the sourced entry as failed
|
||||||
|
// rather than throwing (e.g. from a failed DI resolution).
|
||||||
|
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
|
||||||
|
|
||||||
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => 'attach-1',
|
||||||
|
'origin' => 'documents',
|
||||||
|
'source' => 'default:personal:root:doc1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('error', $result['disposition']);
|
||||||
|
$this->assertCount(0, $result['attachments']);
|
||||||
|
$this->assertCount(1, $result['failed']);
|
||||||
|
$this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']);
|
||||||
|
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeManager(Stub $documentsManager, bool $documentsModuleEnabled = true): CompositionManager {
|
||||||
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
|
||||||
|
$moduleManager = $this->createStub(ModuleManager::class);
|
||||||
|
if ($documentsModuleEnabled) {
|
||||||
|
$module = $this->createStub(ModuleObject::class);
|
||||||
|
$module->method('enabled')->willReturn(true);
|
||||||
|
$moduleManager->method('fetch')->willReturn($module);
|
||||||
|
} else {
|
||||||
|
$moduleManager->method('fetch')->willReturn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $this->createStub(ContainerInterface::class);
|
||||||
|
$container->method('has')->willReturn($documentsModuleEnabled);
|
||||||
|
$container->method('get')->willReturn($documentsManager);
|
||||||
|
|
||||||
|
return new CompositionManager(
|
||||||
|
container: $container,
|
||||||
|
moduleManager: $moduleManager,
|
||||||
|
compositionStore: $this->compositionStore,
|
||||||
|
mailManager: $mailManager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stageEmptyComposition(): string {
|
||||||
|
$compositionId = 'draft-' . uniqid();
|
||||||
|
$this->compositionStore->compositionSave(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
|
'identifier' => $compositionId,
|
||||||
|
'attachments' => [],
|
||||||
|
]);
|
||||||
|
return $compositionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub {
|
||||||
|
$properties = $this->createStub(EntityPropertiesBaseInterface::class);
|
||||||
|
$properties->method('getLabel')->willReturn($label);
|
||||||
|
$properties->method('getMime')->willReturn($mime);
|
||||||
|
$properties->method('size')->willReturn(0);
|
||||||
|
|
||||||
|
$entity = $this->createStub(EntityBaseInterface::class);
|
||||||
|
$entity->method('urn')->willReturn($urn);
|
||||||
|
$entity->method('getProperties')->willReturn($properties);
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return resource */
|
||||||
|
private function makeStream(string $contents) {
|
||||||
|
$resource = fopen('php://memory', 'r+');
|
||||||
|
fwrite($resource, $contents);
|
||||||
|
rewind($resource);
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteDir(string $path): void {
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($path) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$child = $path . '/' . $entry;
|
||||||
|
if (is_dir($child)) {
|
||||||
|
$this->deleteDir($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink($child);
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user