From 33a06c65096f88a82b1dac3a835259eeffb0a854 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 29 Aug 2026 12:29:15 -0400 Subject: [PATCH] feat: implement preview mechanism Signed-off-by: Sebastian Krupinski --- lib/Controllers/TransferController.php | 50 ++++++++++++++------ lib/Manager.php | 64 ++++++++++++++++++++++++++ src/composables/useDocumentManager.ts | 6 +++ 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lib/Controllers/TransferController.php b/lib/Controllers/TransferController.php index fa9a3f0..7e036f1 100644 --- a/lib/Controllers/TransferController.php +++ b/lib/Controllers/TransferController.php @@ -50,11 +50,7 @@ class TransferController extends ControllerAbstract * * GET /download/entity/{provider}/{service}/{collection}/{identifier} */ - #[AuthenticatedRoute( - '/download/entity/{provider}/{service}/{collection}/{identifier}', - name: 'document_manager.download.entity', - methods: ['GET'] - )] + #[AuthenticatedRoute('/download/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.download.entity', methods: ['GET'])] public function downloadEntity(string $provider, string $service, string $collection, string $identifier): Response { $tenantId = $this->tenantContext->identifier(); $userId = $this->identityContext->identifier(); @@ -124,16 +120,44 @@ class TransferController extends ControllerAbstract } } + #[AuthenticatedRoute('/preview/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.preview.entity', methods: ['GET'])] + public function previewEntity(string $provider, string $service, string $collection, string $identifier, string $variant = 'popover'): Response { + try { + $preview = $this->manager->entityPreview( + $this->tenantContext->identifier(), + $this->identityContext->identifier(), + new EntityIdentifier($provider, $service, $collection, $identifier), + $variant, + ); + if ($preview === null) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 404, 'message' => 'Preview unavailable'], + ], Response::HTTP_NOT_FOUND); + } + + $response = new StreamedResponse($preview->result->resource->stream()); + $response->headers->set('Content-Type', $preview->result->resource->mimeType()); + $response->headers->set('Content-Length', (string) $preview->blob->size); + $response->headers->set('ETag', '"' . trim($preview->blob->etag, '"') . '"'); + $response->headers->set('Cache-Control', 'private, max-age=3600'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + return $response; + } catch (Throwable $throwable) { + $this->logger->error('Document preview failed', ['exception' => $throwable]); + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 500, 'message' => 'Preview unavailable'], + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + /** * Download multiple files as a ZIP archive * * GET /download/archive?provider=...&service=...&ids[]=...&ids[]=... */ - #[AuthenticatedRoute( - '/download/archive', - name: 'manager.download.archive', - methods: ['GET'] - )] + #[AuthenticatedRoute('/download/archive', name: 'manager.download.archive', methods: ['GET'])] public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response { $tenantId = $this->tenantContext->identifier(); $userId = $this->identityContext->identifier(); @@ -215,11 +239,7 @@ class TransferController extends ControllerAbstract * * GET /download/collection/{provider}/{service}/{identifier} */ - #[AuthenticatedRoute( - '/download/collection/{provider}/{service}/{identifier}', - name: 'manager.download.collection', - methods: ['GET'] - )] + #[AuthenticatedRoute('/download/collection/{provider}/{service}/{identifier}', name: 'manager.download.collection', methods: ['GET'])] public function downloadCollection(string $provider, string $service, string $identifier): Response { $tenantId = $this->tenantContext->identifier(); $userId = $this->identityContext->identifier(); diff --git a/lib/Manager.php b/lib/Manager.php index 2f05ed6..72fbbb5 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace KTXM\DocumentsManager; use InvalidArgumentException; +use RuntimeException; +use KTXC\Preview\CachedPreview; +use KTXC\Preview\PreviewManager; use KTXC\Resource\ProviderManager; use KTXF\Documents\Collection\CollectionBaseInterface; use KTXF\Documents\Collection\CollectionPropertiesMutableInterface; @@ -18,6 +21,8 @@ use KTXF\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Documents\Service\ServiceEntityMutableInterface; use KTXF\Documents\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; +use KTXF\Preview\PreviewSource; +use KTXF\Resource\BinaryResource; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; @@ -37,6 +42,7 @@ class Manager { public function __construct( private LoggerInterface $logger, private ProviderManager $providerManager, + private PreviewManager $previewManager, ) { } /** @@ -1342,4 +1348,62 @@ class Manager { return $this->serviceForWrite($tenantId, $userId, $target)->entityWriteChunk($target, $offset, $data); } + /** + * Resolve an authorized entity and lazily generate or retrieve its preview. + */ + public function entityPreview(string $tenantId, string $userId, EntityIdentifier $target, string $variant = 'popover'): ?CachedPreview { + $entities = $this->entityFetchBulk($tenantId, $userId, $target); + $entity = $entities[$target->entity()] ?? reset($entities); + if (!$entity instanceof EntityBaseInterface) { + return null; + } + + $properties = $entity->getProperties(); + $mimeType = $properties->getMime(); + $size = $properties->size(); + $signature = $entity->signature() ?? hash('sha256', implode('|', [ + (string) $target, + $entity->modified()?->format('U.u') ?? '', + (string) $size, + $mimeType, + ])); + + $source = new PreviewSource( + sourceType: 'document', + identity: (string) $target, + signature: $signature, + mimeType: $mimeType, + size: $size, + resourceFactory: function () use ($tenantId, $userId, $target, $properties, $mimeType): BinaryResource { + $stream = $this->entityReadStream($tenantId, $userId, $target); + if (!is_resource($stream)) { + throw new RuntimeException('Document content is unavailable'); + } + + return new BinaryResource( + $properties->getLabel(), + $mimeType, + (static function () use ($stream): \Generator { + try { + while (!feof($stream)) { + $chunk = fread($stream, 65536); + if ($chunk === false) { + throw new RuntimeException('Failed to read document content'); + } + if ($chunk !== '') { + yield $chunk; + } + } + } finally { + fclose($stream); + } + })(), + ); + }, + ); + + return $this->previewManager->fetch($tenantId, $source, $variant); + } + + } diff --git a/src/composables/useDocumentManager.ts b/src/composables/useDocumentManager.ts index 7add667..8b34a8b 100644 --- a/src/composables/useDocumentManager.ts +++ b/src/composables/useDocumentManager.ts @@ -203,6 +203,11 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}` } + const getEntityPreviewUrl = (entityId: string, collectionId?: string | null): string => { + const collection = collectionId ?? currentLocation.value + return `${TRANSFER_BASE_URL}/preview/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}?variant=popover` + } + // Download a single file const downloadEntity = (entityId: string, collectionId?: string | null): void => { const collection = collectionId ?? currentLocation.value @@ -280,6 +285,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) { readFile, writeFile, getEntityUrl, + getEntityPreviewUrl, downloadEntity, downloadCollection, downloadArchive,