From 4839f822fa778d31d2ac79f7cf4eff9aadf4e306 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 15 Aug 2026 12:55:59 -0400 Subject: [PATCH] feat: add attach from documents Signed-off-by: Sebastian --- lib/CompositionManager.php | 252 +++++++++++++++++---- lib/Stores/CompositionStore.php | 42 +--- src/components/MessageComposer.vue | 33 ++- src/components/composer/ComposerEditor.vue | 11 +- src/services/documentsSelector.ts | 27 +++ src/stores/mailCompositionStore.ts | 81 +++++-- src/types/composer.ts | 2 +- src/types/composition.ts | 8 +- tests/php/Unit/CompositionManagerTest.php | 242 ++++++++++++++++++++ 9 files changed, 591 insertions(+), 107 deletions(-) create mode 100644 src/services/documentsSelector.ts create mode 100644 tests/php/Unit/CompositionManagerTest.php diff --git a/lib/CompositionManager.php b/lib/CompositionManager.php index 0f225ff..0b0e88e 100644 --- a/lib/CompositionManager.php +++ b/lib/CompositionManager.php @@ -5,21 +5,29 @@ declare(strict_types=1); namespace KTXM\Mail; use InvalidArgumentException; +use KTXC\Module\ModuleManager; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\MessagePart; use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Service\ServiceEntitySubmitInterface; 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\Utile\UUID; use KTXM\Mail\Stores\CompositionStore; use KTXM\MailManager\Manager as MailManager; +use KTXM\DocumentsManager\Manager as DocumentsManager; +use Psr\Container\ContainerInterface; class CompositionManager { + private const DOCUMENTS_MODULE_HANDLE = 'documents_manager'; + public function __construct( + private readonly ContainerInterface $container, + private readonly ModuleManager $moduleManager, private readonly CompositionStore $compositionStore, private readonly MailManager $mailManager, ) {} @@ -75,7 +83,7 @@ class CompositionManager { data: $data, ); // supplement the attachment metadata with source information - $meta['origin'] = 'source'; + $meta['origin'] = 'message'; $meta['source'] = $source; $meta['partId'] = $attachment->getId(); $meta['blobId'] = $attachment->getBlobId(); @@ -292,40 +300,13 @@ class CompositionManager { return $properties; } - - /** - * @param array $entries - * @return array - */ - 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 { $result = [ 'composition' => $composition, 'attachments' => [], ]; - + // fetch the composition to ensure it exists $composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition); if ($composed === null) { $result['disposition'] = 'error'; @@ -335,32 +316,169 @@ class CompositionManager { ]; return $result; } - + // separate attachments by type + $uploads = []; + $documents = []; foreach ($attachments as $attachment) { - if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') { - $identifier = UUID::v4(); - } else { - $identifier = $attachment['identifier']; + if (isset($attachment['origin']) && $attachment['origin'] === 'documents') { + $documents[] = $attachment; + } elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') { + $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', + ]; + } + + 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'); } - $meta = $this->compositionStore->attachmentStageFromBase64( + $data = new BinaryResource( + $attachment['name'] ?? 'unknown.bin', + $attachment['type'] ?? 'application/octet-stream', + $this->stringToGenerator($decoded), + ); + + $meta = $this->compositionStore->attachmentStageFromStream( tenantId: $tenantId, userId: $userId, compositionId: $composition, attachmentId: $identifier, - name: $attachment['name'] ?? 'unknown.bin', - type: $attachment['type'] ?? 'application/octet-stream', - data: $attachment['data'] ?? '', + data: $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; } - $this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed); - - $result['disposition'] = 'added'; - $result['attachments'] = $composed['attachments']; - return $result; + return $failed; } public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array { @@ -393,4 +511,48 @@ class CompositionManager { $result['disposition'] = 'removed'; return $result; } + + /** + * @param array $entries + * @return array + */ + 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); + } } \ No newline at end of file diff --git a/lib/Stores/CompositionStore.php b/lib/Stores/CompositionStore.php index edc88ff..0995dcd 100644 --- a/lib/Stores/CompositionStore.php +++ b/lib/Stores/CompositionStore.php @@ -54,13 +54,7 @@ final class CompositionStore { return true; } - public function attachmentStageFromStream( - string $tenantId, - string $userId, - string $compositionId, - string $attachmentId, - BinaryResource $data, - ): array { + public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array { $attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId); if (!is_dir($attachmentDir)) { mkdir($attachmentDir, 0755, true); @@ -91,40 +85,6 @@ final class CompositionStore { 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 { $path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob'; if (!file_exists($path)) { diff --git a/src/components/MessageComposer.vue b/src/components/MessageComposer.vue index 16e24ad..90efb7c 100644 --- a/src/components/MessageComposer.vue +++ b/src/components/MessageComposer.vue @@ -12,6 +12,7 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models import { useMailOperationsStore } from '@/stores/mailOperationsStore' import { useMailCompositionStore } from '@/stores/mailCompositionStore' import { ComposerMode } from '@/types/composer' +import { documentsSelectorComponent } from '@/services/documentsSelector' import ComposerToolbar from '@/components/composer/ComposerToolbar.vue' import ComposerSender from '@/components/composer/ComposerSender.vue' import ComposerRecipients from '@/components/composer/ComposerRecipients.vue' @@ -48,6 +49,8 @@ const { const showCc = ref(false) const showBcc = ref(false) const applyingDraftToEditor = ref(false) +const showDocumentSelector = ref(false) +const documentSelector = computed(() => documentsSelectorComponent()) const sender = computed({ get: () => activeDraft.value?.sender ?? null, @@ -166,8 +169,23 @@ const handleSend = async () => { await compositionStore.sendDraft() } -const handleAttach = async (files: File[]) => { - await compositionStore.addAttachments(files) +const handleAttachFromDevice = async (files: File[]) => { + 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) => { @@ -264,9 +282,18 @@ const isActive = (name: string, attrs?: any) => { @bullet-list="toggleBulletList" @ordered-list="toggleOrderedList" @link="toggleLink" - @attach="handleAttach" + @attach-from-device="handleAttachFromDevice" + @attach-from-documents="handleAttachFromDocuments" /> + + diff --git a/src/components/composer/ComposerEditor.vue b/src/components/composer/ComposerEditor.vue index fa3424a..6a621a1 100644 --- a/src/components/composer/ComposerEditor.vue +++ b/src/components/composer/ComposerEditor.vue @@ -2,6 +2,7 @@ import { ref } from 'vue' import type { PropType } from 'vue' import { EditorContent, type Editor } from '@tiptap/vue-3' +import { documentsSelectorAvailable } from '@/services/documentsSelector' defineProps({ editor: { @@ -41,7 +42,8 @@ const emit = defineEmits<{ bulletList: [] orderedList: [] link: [] - attach: [files: File[]] + attachFromDevice: [files: File[]] + attachFromDocuments: [] }>() const fileInput = ref(null) @@ -54,7 +56,7 @@ function handleFileChange(event: Event) { const input = event.target as HTMLInputElement | null const files = input?.files ? Array.from(input.files) : [] if (files.length > 0) { - emit('attach', files) + emit('attachFromDevice', files) } if (input) { input.value = '' @@ -142,6 +144,11 @@ function handleFileChange(event: Event) { mdi-paperclip Attach Files + + + mdi-folder-search-outline + Attach from Documents + diff --git a/src/services/documentsSelector.ts b/src/services/documentsSelector.ts new file mode 100644 index 0000000..0dfb338 --- /dev/null +++ b/src/services/documentsSelector.ts @@ -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) + : null +} diff --git a/src/stores/mailCompositionStore.ts b/src/stores/mailCompositionStore.ts index 43b7724..080f9db 100644 --- a/src/stores/mailCompositionStore.ts +++ b/src/stores/mailCompositionStore.ts @@ -14,6 +14,7 @@ import type { import { ComposerMode } from '@/types/composer' import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer' import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models' +import type { EntityIdentifier } from '@MailManager/types/common' import type { MessageAddressInterface } from '@MailManager/types/message' export const useMailCompositionStore = defineStore('mailCompositionStore', () => { @@ -274,7 +275,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => queueSave(false) } - async function addAttachments(files: File[]) { + async function addAttachmentsFromDevice(files: File[]) { if (!activeDraft.value || files.length === 0) { return } @@ -288,7 +289,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => return [identifier, { identifier, composition, - origin: 'upload', + origin: 'device', name: file.name, type: file.type || 'application/octet-stream', 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) { if (!activeDraft.value) { return @@ -424,17 +485,14 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () => updateRecipients, updateSubject, updateBody, - addAttachments, + addAttachmentsFromDevice, + addAttachmentsFromDocuments, removeAttachment, flushSave, } }) -function buildDraft( - mode: ComposerMode, - sender: ComposerSenderIdentity, - source?: EntityObject | MessageAddressObject | null, -): ComposerDraft { +function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source?: EntityObject | MessageAddressObject | null): ComposerDraft { const composition = createIdentifier() const freshMessage = buildMessage(mode, source) const freshAttachments = buildAttachments(composition, mode, source) @@ -452,10 +510,7 @@ function buildDraft( } } -function buildMessage( - mode: ComposerMode, - source: EntityObject | MessageAddressObject | null | undefined, -): ComposerDraftMessage { +function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined): ComposerDraftMessage { if (!source) { return emptyMessage() @@ -521,7 +576,7 @@ function buildAttachments(composition: string, mode: ComposerMode, source?: Enti attachments[id] = { identifier: id, composition: composition, - origin: 'source', + origin: 'message', name: attachment.name || '', type: attachment.type || 'application/octet-stream', size: attachment.size || 0, diff --git a/src/types/composer.ts b/src/types/composer.ts index 75810ae..2e8953f 100644 --- a/src/types/composer.ts +++ b/src/types/composer.ts @@ -11,7 +11,7 @@ export enum ComposerMode { export interface ComposerDraftAttachment { identifier: string composition: string - origin: 'source' | 'upload' + origin: 'device' | 'message' | 'documents' name: string type: string size: number diff --git a/src/types/composition.ts b/src/types/composition.ts index 31ea426..b310df3 100644 --- a/src/types/composition.ts +++ b/src/types/composition.ts @@ -22,7 +22,7 @@ export interface CompositionMessageInterface { export interface CompositionAttachmentInterface { identifier: string composition: string - origin: 'source' | 'upload' + origin: 'device' | 'message' | 'documents' name: string type: string size: number @@ -94,13 +94,17 @@ export interface CompositionAttachmentAddRequest { } export interface CompositionAttachmentAddResponse { - disposition: 'added' | 'error' + disposition: 'added' | 'partial' | 'error' error?: { type: string message: string } composition: string attachments: Record + failed?: { + identifier: string + message: string + }[] } export interface CompositionAttachmentRemoveRequest { diff --git a/tests/php/Unit/CompositionManagerTest.php b/tests/php/Unit/CompositionManagerTest.php new file mode 100644 index 0000000..ec99078 --- /dev/null +++ b/tests/php/Unit/CompositionManagerTest.php @@ -0,0 +1,242 @@ +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); + } +}