feat: implement preview mechanism

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-29 12:29:15 -04:00
parent f80e3679b6
commit 33a06c6509
3 changed files with 105 additions and 15 deletions
+35 -15
View File
@@ -50,11 +50,7 @@ class TransferController extends ControllerAbstract
* *
* GET /download/entity/{provider}/{service}/{collection}/{identifier} * GET /download/entity/{provider}/{service}/{collection}/{identifier}
*/ */
#[AuthenticatedRoute( #[AuthenticatedRoute('/download/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.download.entity', methods: ['GET'])]
'/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 { public function downloadEntity(string $provider, string $service, string $collection, string $identifier): Response {
$tenantId = $this->tenantContext->identifier(); $tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->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 * Download multiple files as a ZIP archive
* *
* GET /download/archive?provider=...&service=...&ids[]=...&ids[]=... * GET /download/archive?provider=...&service=...&ids[]=...&ids[]=...
*/ */
#[AuthenticatedRoute( #[AuthenticatedRoute('/download/archive', name: 'manager.download.archive', methods: ['GET'])]
'/download/archive',
name: 'manager.download.archive',
methods: ['GET']
)]
public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response { public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response {
$tenantId = $this->tenantContext->identifier(); $tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier(); $userId = $this->identityContext->identifier();
@@ -215,11 +239,7 @@ class TransferController extends ControllerAbstract
* *
* GET /download/collection/{provider}/{service}/{identifier} * GET /download/collection/{provider}/{service}/{identifier}
*/ */
#[AuthenticatedRoute( #[AuthenticatedRoute('/download/collection/{provider}/{service}/{identifier}', name: 'manager.download.collection', methods: ['GET'])]
'/download/collection/{provider}/{service}/{identifier}',
name: 'manager.download.collection',
methods: ['GET']
)]
public function downloadCollection(string $provider, string $service, string $identifier): Response { public function downloadCollection(string $provider, string $service, string $identifier): Response {
$tenantId = $this->tenantContext->identifier(); $tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier(); $userId = $this->identityContext->identifier();
+64
View File
@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace KTXM\DocumentsManager; namespace KTXM\DocumentsManager;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException;
use KTXC\Preview\CachedPreview;
use KTXC\Preview\PreviewManager;
use KTXC\Resource\ProviderManager; use KTXC\Resource\ProviderManager;
use KTXF\Documents\Collection\CollectionBaseInterface; use KTXF\Documents\Collection\CollectionBaseInterface;
use KTXF\Documents\Collection\CollectionPropertiesMutableInterface; use KTXF\Documents\Collection\CollectionPropertiesMutableInterface;
@@ -18,6 +21,8 @@ use KTXF\Documents\Service\ServiceCollectionMutableInterface;
use KTXF\Documents\Service\ServiceEntityMutableInterface; use KTXF\Documents\Service\ServiceEntityMutableInterface;
use KTXF\Documents\Service\ServiceMutableInterface; use KTXF\Documents\Service\ServiceMutableInterface;
use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Filter\IFilter;
use KTXF\Preview\PreviewSource;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ResourceIdentifiers;
@@ -37,6 +42,7 @@ class Manager {
public function __construct( public function __construct(
private LoggerInterface $logger, private LoggerInterface $logger,
private ProviderManager $providerManager, private ProviderManager $providerManager,
private PreviewManager $previewManager,
) { } ) { }
/** /**
@@ -1342,4 +1348,62 @@ class Manager {
return $this->serviceForWrite($tenantId, $userId, $target)->entityWriteChunk($target, $offset, $data); 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);
}
} }
+6
View File
@@ -203,6 +203,11 @@ export function useDocumentManager(options: UseDocumentManagerOptions) {
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}` 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 // Download a single file
const downloadEntity = (entityId: string, collectionId?: string | null): void => { const downloadEntity = (entityId: string, collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value const collection = collectionId ?? currentLocation.value
@@ -280,6 +285,7 @@ export function useDocumentManager(options: UseDocumentManagerOptions) {
readFile, readFile,
writeFile, writeFile,
getEntityUrl, getEntityUrl,
getEntityPreviewUrl,
downloadEntity, downloadEntity,
downloadCollection, downloadCollection,
downloadArchive, downloadArchive,