|
|
@@ -4,9 +4,9 @@ import {
|
|
|
AssetType,
|
|
|
AssignAssetsToChannelInput,
|
|
|
CreateAssetInput,
|
|
|
- CreateAssetResult,
|
|
|
DeletionResponse,
|
|
|
DeletionResult,
|
|
|
+ LanguageCode,
|
|
|
LogicalOperator,
|
|
|
Permission,
|
|
|
UpdateAssetInput,
|
|
|
@@ -32,10 +32,12 @@ import { isGraphQlErrorResult } from '../../common/error/error-result';
|
|
|
import { ForbiddenError, InternalServerError } from '../../common/error/errors';
|
|
|
import { MimeTypeError } from '../../common/error/generated-graphql-admin-errors';
|
|
|
import { ChannelAware } from '../../common/types/common-types';
|
|
|
+import { Translated } from '../../common/types/locale-types';
|
|
|
import { getAssetType, idsAreEqual } from '../../common/utils';
|
|
|
import { ConfigService } from '../../config/config.service';
|
|
|
import { Logger } from '../../config/logger/vendure-logger';
|
|
|
import { TransactionalConnection } from '../../connection/transactional-connection';
|
|
|
+import { AssetTranslation } from '../../entity/asset/asset-translation.entity';
|
|
|
import { Asset } from '../../entity/asset/asset.entity';
|
|
|
import { OrderableAsset } from '../../entity/asset/orderable-asset.entity';
|
|
|
import { VendureEntity } from '../../entity/base/base.entity';
|
|
|
@@ -47,6 +49,8 @@ import { AssetChannelEvent } from '../../event-bus/events/asset-channel-event';
|
|
|
import { AssetEvent } from '../../event-bus/events/asset-event';
|
|
|
import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
|
|
|
import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
|
|
|
+import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
|
|
|
+import { TranslatorService } from '../helpers/translator/translator.service';
|
|
|
import { patchEntity } from '../helpers/utils/patch-entity';
|
|
|
|
|
|
import { ChannelService } from './channel.service';
|
|
|
@@ -102,6 +106,8 @@ export class AssetService {
|
|
|
private channelService: ChannelService,
|
|
|
private roleService: RoleService,
|
|
|
private customFieldRelationService: CustomFieldRelationService,
|
|
|
+ private readonly translatableSaver: TranslatableSaver,
|
|
|
+ private readonly translator: TranslatorService,
|
|
|
) {
|
|
|
this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
|
|
|
.map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
|
|
|
@@ -112,19 +118,23 @@ export class AssetService {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- findOne(ctx: RequestContext, id: ID, relations?: RelationPaths<Asset>): Promise<Asset | undefined> {
|
|
|
+ findOne(
|
|
|
+ ctx: RequestContext,
|
|
|
+ id: ID,
|
|
|
+ relations?: RelationPaths<Asset>,
|
|
|
+ ): Promise<Translated<Asset> | undefined> {
|
|
|
return this.connection
|
|
|
.findOneInChannel(ctx, Asset, id, ctx.channelId, {
|
|
|
relations: relations ?? [],
|
|
|
})
|
|
|
- .then(result => result ?? undefined);
|
|
|
+ .then(result => (result ? this.translator.translate(result, ctx) : undefined));
|
|
|
}
|
|
|
|
|
|
findAll(
|
|
|
ctx: RequestContext,
|
|
|
options?: AssetListOptions,
|
|
|
relations?: RelationPaths<Asset>,
|
|
|
- ): Promise<PaginatedList<Asset>> {
|
|
|
+ ): Promise<PaginatedList<Translated<Asset>>> {
|
|
|
const qb = this.listQueryBuilder.build(Asset, options, {
|
|
|
ctx,
|
|
|
relations: [...(relations ?? []), 'tags'],
|
|
|
@@ -150,7 +160,7 @@ export class AssetService {
|
|
|
});
|
|
|
}
|
|
|
return qb.getManyAndCount().then(([items, totalItems]) => ({
|
|
|
- items,
|
|
|
+ items: items.map(asset => this.translator.translate(asset, ctx)),
|
|
|
totalItems,
|
|
|
}));
|
|
|
}
|
|
|
@@ -206,6 +216,7 @@ export class AssetService {
|
|
|
.createQueryBuilder('entity')
|
|
|
.leftJoinAndSelect('entity.assets', 'orderable_asset')
|
|
|
.leftJoinAndSelect('orderable_asset.asset', 'asset')
|
|
|
+ .leftJoinAndSelect('asset.translations', 'asset_translations')
|
|
|
.leftJoinAndSelect('asset.channels', 'asset_channel')
|
|
|
.where('entity.id = :id', { id: entity.id })
|
|
|
.andWhere('asset_channel.id = :channelId', { channelId: ctx.channelId })
|
|
|
@@ -291,11 +302,11 @@ export class AssetService {
|
|
|
*
|
|
|
* See the [Uploading Files docs](/developer-guide/uploading-files) for an example of usage.
|
|
|
*/
|
|
|
- async create(ctx: RequestContext, input: CreateAssetInput): Promise<CreateAssetResult> {
|
|
|
+ async create(ctx: RequestContext, input: CreateAssetInput): Promise<Translated<Asset> | MimeTypeError> {
|
|
|
const { createReadStream, filename, mimetype } = await input.file;
|
|
|
const { stream, errorPromise } = this.makeStreamGuard(createReadStream);
|
|
|
const result = await Promise.race([
|
|
|
- this.createAssetInternal(ctx, stream, filename, mimetype, input.customFields),
|
|
|
+ this.createAssetInternal(ctx, stream, filename, mimetype, input.customFields, input.translations),
|
|
|
errorPromise,
|
|
|
]);
|
|
|
if (isGraphQlErrorResult(result)) {
|
|
|
@@ -307,29 +318,49 @@ export class AssetService {
|
|
|
result.tags = tags;
|
|
|
await this.connection.getRepository(ctx, Asset).save(result);
|
|
|
}
|
|
|
- await this.eventBus.publish(new AssetEvent(ctx, result, 'created', input));
|
|
|
- return result;
|
|
|
+ const translatedAsset = this.translator.translate(result, ctx);
|
|
|
+ await this.eventBus.publish(new AssetEvent(ctx, translatedAsset, 'created', input));
|
|
|
+ return translatedAsset;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @description
|
|
|
* Updates the name, focalPoint, tags & custom fields of an Asset.
|
|
|
*/
|
|
|
- async update(ctx: RequestContext, input: UpdateAssetInput): Promise<Asset> {
|
|
|
+ async update(ctx: RequestContext, input: UpdateAssetInput): Promise<Translated<Asset>> {
|
|
|
const asset = await this.connection.getEntityOrThrow(ctx, Asset, input.id);
|
|
|
if (input.focalPoint) {
|
|
|
const to3dp = (x: number) => +x.toFixed(3);
|
|
|
input.focalPoint.x = to3dp(input.focalPoint.x);
|
|
|
input.focalPoint.y = to3dp(input.focalPoint.y);
|
|
|
}
|
|
|
- patchEntity(asset, omit(input, ['tags']));
|
|
|
+ patchEntity(asset, omit(input, ['tags', 'name', 'translations']));
|
|
|
await this.customFieldRelationService.updateRelations(ctx, Asset, input, asset);
|
|
|
if (input.tags) {
|
|
|
asset.tags = await this.tagService.valuesToTags(ctx, input.tags);
|
|
|
}
|
|
|
- const updatedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
|
|
|
- await this.eventBus.publish(new AssetEvent(ctx, updatedAsset, 'updated', input));
|
|
|
- return updatedAsset;
|
|
|
+ // Handle translations
|
|
|
+ const translationsInput = input.translations ?? [];
|
|
|
+ // For backward compatibility: if name is provided without translations, update the current language translation
|
|
|
+ if (input.name != null && !translationsInput.some(t => t.languageCode === ctx.languageCode)) {
|
|
|
+ translationsInput.push({ languageCode: ctx.languageCode, name: input.name });
|
|
|
+ }
|
|
|
+ // Save asset first to ensure it exists for translation foreign key
|
|
|
+ const savedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
|
|
|
+ if (translationsInput.length > 0) {
|
|
|
+ await this.translatableSaver.update({
|
|
|
+ ctx,
|
|
|
+ input: { id: savedAsset.id, translations: translationsInput },
|
|
|
+ entityType: Asset,
|
|
|
+ translationType: AssetTranslation,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const translatedAsset = await this.findOne(ctx, savedAsset.id);
|
|
|
+ if (!translatedAsset) {
|
|
|
+ throw new InternalServerError('error.entity-not-found');
|
|
|
+ }
|
|
|
+ await this.eventBus.publish(new AssetEvent(ctx, translatedAsset, 'updated', input));
|
|
|
+ return translatedAsset;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -402,7 +433,10 @@ export class AssetService {
|
|
|
return this.deleteUnconditional(ctx, assets);
|
|
|
}
|
|
|
|
|
|
- async assignToChannel(ctx: RequestContext, input: AssignAssetsToChannelInput): Promise<Asset[]> {
|
|
|
+ async assignToChannel(
|
|
|
+ ctx: RequestContext,
|
|
|
+ input: AssignAssetsToChannelInput,
|
|
|
+ ): Promise<Array<Translated<Asset>>> {
|
|
|
const hasPermission = await this.roleService.userHasPermissionOnChannel(
|
|
|
ctx,
|
|
|
input.channelId,
|
|
|
@@ -426,30 +460,34 @@ export class AssetService {
|
|
|
);
|
|
|
}),
|
|
|
);
|
|
|
- return this.connection.findByIdsInChannel(
|
|
|
+ const updatedAssets = await this.connection.findByIdsInChannel(
|
|
|
ctx,
|
|
|
Asset,
|
|
|
assets.map(a => a.id),
|
|
|
ctx.channelId,
|
|
|
{},
|
|
|
);
|
|
|
+ return updatedAssets.map(asset => this.translator.translate(asset, ctx));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @description
|
|
|
* Create an Asset from a file stream, for example to create an Asset during data import.
|
|
|
*/
|
|
|
- async createFromFileStream(stream: ReadStream, ctx?: RequestContext): Promise<CreateAssetResult>;
|
|
|
+ async createFromFileStream(
|
|
|
+ stream: ReadStream,
|
|
|
+ ctx?: RequestContext,
|
|
|
+ ): Promise<Translated<Asset> | MimeTypeError>;
|
|
|
async createFromFileStream(
|
|
|
stream: Readable,
|
|
|
filePath: string,
|
|
|
ctx?: RequestContext,
|
|
|
- ): Promise<CreateAssetResult>;
|
|
|
+ ): Promise<Translated<Asset> | MimeTypeError>;
|
|
|
async createFromFileStream(
|
|
|
stream: ReadStream | Readable,
|
|
|
maybeFilePathOrCtx?: string | RequestContext,
|
|
|
maybeCtx?: RequestContext,
|
|
|
- ): Promise<CreateAssetResult> {
|
|
|
+ ): Promise<Translated<Asset> | MimeTypeError> {
|
|
|
const { assetImportStrategy } = this.configService.importExportOptions;
|
|
|
const filePathFromArgs =
|
|
|
maybeFilePathOrCtx instanceof RequestContext ? undefined : maybeFilePathOrCtx;
|
|
|
@@ -464,7 +502,11 @@ export class AssetService {
|
|
|
: maybeCtx instanceof RequestContext
|
|
|
? maybeCtx
|
|
|
: RequestContext.empty();
|
|
|
- return this.createAssetInternal(ctx, stream, filename, mimetype);
|
|
|
+ const result = await this.createAssetInternal(ctx, stream, filename, mimetype);
|
|
|
+ if (isGraphQlErrorResult(result)) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ return this.translator.translate(result, ctx);
|
|
|
} else {
|
|
|
throw new InternalServerError('error.path-should-be-a-string-got-buffer');
|
|
|
}
|
|
|
@@ -522,6 +564,7 @@ export class AssetService {
|
|
|
filename: string,
|
|
|
mimetype: string,
|
|
|
customFields?: { [key: string]: any },
|
|
|
+ translations?: Array<{ languageCode: LanguageCode; name?: string | null; customFields?: any }>,
|
|
|
): Promise<Asset | MimeTypeError> {
|
|
|
const { assetOptions } = this.configService;
|
|
|
if (!this.validateMimeType(mimetype)) {
|
|
|
@@ -548,11 +591,11 @@ export class AssetService {
|
|
|
const type = getAssetType(mimetype);
|
|
|
const { width, height } = this.getDimensions(type === AssetType.IMAGE ? sourceFile : preview);
|
|
|
|
|
|
+ // Save asset first
|
|
|
const asset = new Asset({
|
|
|
type,
|
|
|
width,
|
|
|
height,
|
|
|
- name: path.basename(sourceFileName),
|
|
|
fileSize: sourceFile.byteLength,
|
|
|
mimeType: mimetype,
|
|
|
source: sourceFileIdentifier,
|
|
|
@@ -561,7 +604,41 @@ export class AssetService {
|
|
|
customFields,
|
|
|
});
|
|
|
await this.channelService.assignToCurrentChannel(asset, ctx);
|
|
|
- return this.connection.getRepository(ctx, Asset).save(asset);
|
|
|
+ const savedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
|
|
|
+
|
|
|
+ // Create and save translations with the base relationship set
|
|
|
+ // Use the original filename for the default translation name
|
|
|
+ const defaultName = filename;
|
|
|
+ let assetTranslations: AssetTranslation[];
|
|
|
+ if (translations && translations.length > 0) {
|
|
|
+ assetTranslations = translations.map(
|
|
|
+ t =>
|
|
|
+ new AssetTranslation({
|
|
|
+ languageCode: t.languageCode,
|
|
|
+ name: t.name ?? defaultName,
|
|
|
+ customFields: t.customFields,
|
|
|
+ base: savedAsset,
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ // Create default translation using context language
|
|
|
+ assetTranslations = [
|
|
|
+ new AssetTranslation({
|
|
|
+ languageCode: ctx.languageCode,
|
|
|
+ name: defaultName,
|
|
|
+ base: savedAsset,
|
|
|
+ }),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save translations
|
|
|
+ const savedTranslations = await this.connection
|
|
|
+ .getRepository(ctx, AssetTranslation)
|
|
|
+ .save(assetTranslations);
|
|
|
+
|
|
|
+ // Return the asset with translations eagerly loaded
|
|
|
+ savedAsset.translations = savedTranslations;
|
|
|
+ return savedAsset;
|
|
|
}
|
|
|
|
|
|
private async getSourceFileName(ctx: RequestContext, fileName: string): Promise<string> {
|