diff --git a/packages/api-elasticsearch/src/sort.ts b/packages/api-elasticsearch/src/sort.ts index 7080c3bf069..ff32ecf6f2c 100644 --- a/packages/api-elasticsearch/src/sort.ts +++ b/packages/api-elasticsearch/src/sort.ts @@ -1,5 +1,5 @@ import WebinyError from "@webiny/error"; -import { FieldSortOptions, SortType, SortOrder } from "~/types"; +import { FieldSortOptions, SortOrder, SortType } from "~/types"; import { ElasticsearchFieldPlugin } from "~/plugins"; const sortRegExp = new RegExp(/^([a-zA-Z-0-9_@]+)_(ASC|DESC)$/); @@ -31,7 +31,7 @@ export const createSort = (params: CreateSortParams): SortType => { /** * Cast as string because nothing else should be allowed yet. */ - return sort.reduce((acc, value) => { + const result = sort.reduce((acc, value) => { if (typeof value !== "string") { throw new WebinyError(`Sort as object is not supported..`); } @@ -61,4 +61,13 @@ export const createSort = (params: CreateSortParams): SortType => { return acc; }, {} as Record); + /** + * If we do not have id in the sort, we add it as we need a tie_breaker for the Elasticsearch to be able to sort consistently. + */ + if (!result["id.keyword"] && !result["id"]) { + result["id.keyword"] = { + order: "asc" + }; + } + return result; }; diff --git a/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts index a2cafb0c368..7847b083597 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts @@ -10,6 +10,7 @@ import { SharpTransform } from "~/assetDelivery/s3/SharpTransform"; export type AssetDeliveryParams = Parameters[0] & { imageResizeWidths?: number[]; presignedUrlTtl?: number; + assetStreamingMaxSize?: number; }; export const assetDeliveryConfig = (params: AssetDeliveryParams) => { @@ -19,6 +20,11 @@ export const assetDeliveryConfig = (params: AssetDeliveryParams) => { const { presignedUrlTtl = 900, imageResizeWidths = [100, 300, 500, 750, 1000, 1500, 2500], + /** + * Even though Lambda's response payload limit is 6,291,556 bytes, we leave some room for the response envelope. + * We had situations where a 4.7MB file would cause the payload to go over the limit, so let's be on the safe side. + */ + assetStreamingMaxSize = 4718592, ...baseParams } = params; @@ -35,7 +41,7 @@ export const assetDeliveryConfig = (params: AssetDeliveryParams) => { }); config.decorateAssetOutputStrategy(() => { - return new S3OutputStrategy(s3, bucket, presignedUrlTtl); + return new S3OutputStrategy(s3, bucket, presignedUrlTtl, assetStreamingMaxSize); }); config.decorateAssetTransformationStrategy(() => { diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts index ea574101aed..894cfd33f85 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts @@ -3,8 +3,6 @@ import { GetObjectCommand, getSignedUrl, S3 } from "@webiny/aws-sdk/client-s3"; import { S3RedirectAssetReply } from "~/assetDelivery/s3/S3RedirectAssetReply"; import { S3StreamAssetReply } from "~/assetDelivery/s3/S3StreamAssetReply"; -const MAX_RETURN_CONTENT_LENGTH = 4915200; // ~4.8 MB - /** * This strategy outputs an asset taking into account the size of the asset contents. * If the asset is larger than 5MB, a presigned URL will be generated, and a redirect will happen. @@ -13,21 +11,30 @@ export class S3OutputStrategy implements AssetOutputStrategy { private readonly s3: S3; private readonly bucket: string; private readonly presignedUrlTtl: number; + private readonly assetStreamingMaxSize: number; - constructor(s3: S3, bucket: string, presignedUrlTtl: number) { + constructor(s3: S3, bucket: string, presignedUrlTtl: number, assetStreamingMaxSize: number) { + this.assetStreamingMaxSize = assetStreamingMaxSize; this.presignedUrlTtl = presignedUrlTtl; this.s3 = s3; this.bucket = bucket; } async output(asset: Asset): Promise { - if ((await asset.getSize()) > MAX_RETURN_CONTENT_LENGTH) { + if (asset.getSize() > this.assetStreamingMaxSize) { + console.log( + `Asset size is greater than ${this.assetStreamingMaxSize}; redirecting to a presigned S3 URL.` + ); + return new S3RedirectAssetReply( await this.getPresignedUrl(asset), this.presignedUrlTtl ); } + console.log( + `Asset size is smaller than ${this.assetStreamingMaxSize}; streaming directly from Lambda function.` + ); return new S3StreamAssetReply(asset); } diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts index cb616787810..39eba04f637 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts @@ -26,6 +26,9 @@ export class SharpTransform implements AssetTransformationStrategy { async transform(assetRequest: AssetRequest, asset: Asset): Promise { if (!utils.SUPPORTED_TRANSFORMABLE_IMAGES.includes(asset.getExtension())) { + console.log( + `Transformations/optimizations of ${asset.getContentType()} assets are not supported. Skipping.` + ); return asset; } @@ -45,6 +48,7 @@ export class SharpTransform implements AssetTransformationStrategy { } private async transformAsset(asset: Asset, options: Omit) { + console.log("Transform asset", options); if (options.width) { const { s3, bucket } = this.params; @@ -63,7 +67,15 @@ export class SharpTransform implements AssetTransformationStrategy { const buffer = Buffer.from(await Body.transformToByteArray()); - asset.setContentsReader(new CallableContentsReader(() => buffer)); + const newAsset = asset.withProps({ size: buffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => buffer)); + + console.log(`Return a previously transformed asset`, { + key: transformedAssetKey, + size: newAsset.getSize() + }); + + return newAsset; } catch (e) { const optimizedImage = await this.optimizeAsset(asset); @@ -73,22 +85,33 @@ export class SharpTransform implements AssetTransformationStrategy { /** * `width` is the only transformation we currently support. */ + console.log(`Resize the asset (width: ${width})`); const buffer = await optimizedImage.getContents(); - const transformedBuffer = sharp(buffer, { animated: this.isAssetAnimated(asset) }) + const transformedBuffer = await sharp(buffer, { + animated: this.isAssetAnimated(asset) + }) .resize({ width }) .toBuffer(); /** * Transformations are applied to the optimized image. */ - asset.setContentsReader(new CallableContentsReader(() => transformedBuffer)); + const newAsset = asset.withProps({ size: transformedBuffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => transformedBuffer)); await s3.putObject({ Bucket: bucket, Key: transformedAssetKey, - ContentType: asset.getContentType(), - Body: await asset.getContents() + ContentType: newAsset.getContentType(), + Body: await newAsset.getContents() }); + + console.log(`Return the resized asset`, { + key: transformedAssetKey, + size: newAsset.getSize() + }); + + return newAsset; } } @@ -98,6 +121,13 @@ export class SharpTransform implements AssetTransformationStrategy { private async optimizeAsset(asset: Asset) { const { s3, bucket } = this.params; + console.log("Optimize asset", { + id: asset.getId(), + key: asset.getKey(), + size: asset.getSize(), + type: asset.getContentType() + }); + const assetKey = new AssetKeyGenerator(asset); const optimizedAssetKey = assetKey.getOptimizedImageKey(); @@ -111,10 +141,16 @@ export class SharpTransform implements AssetTransformationStrategy { throw new Error(`Missing image body!`); } + console.log("Return a previously optimized asset", optimizedAssetKey); + const buffer = Buffer.from(await Body.transformToByteArray()); - asset.setContentsReader(new CallableContentsReader(() => buffer)); + const newAsset = asset.withProps({ size: buffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => buffer)); + + return newAsset; } catch (e) { + console.log("Create an optimized version of the original asset", asset.getKey()); // If not found, create an optimized version of the original asset. const buffer = await asset.getContents(); @@ -127,23 +163,26 @@ export class SharpTransform implements AssetTransformationStrategy { const optimization = optimizationMap[asset.getContentType()]; if (!optimization) { - console.log(`no optimizations defined for ${asset.getContentType()}`); + console.log(`No optimizations defined for ${asset.getContentType()}`); return asset; } - const optimizedBuffer = optimization(buffer).toBuffer(); + const optimizedBuffer = await optimization(buffer).toBuffer(); + + console.log("Optimized asset size", optimizedBuffer.length); - asset.setContentsReader(new CallableContentsReader(() => optimizedBuffer)); + const newAsset = asset.withProps({ size: optimizedBuffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => optimizedBuffer)); await s3.putObject({ Bucket: bucket, Key: optimizedAssetKey, - ContentType: asset.getContentType(), - Body: await asset.getContents() + ContentType: newAsset.getContentType(), + Body: await newAsset.getContents() }); - } - return asset; + return newAsset; + } } private isAssetAnimated(asset: Asset) { @@ -160,6 +199,7 @@ export class SharpTransform implements AssetTransformationStrategy { private optimizeJpeg(buffer: Buffer) { return sharp(buffer) .resize({ width: 2560, withoutEnlargement: true, fit: "inside" }) + .withMetadata() .toFormat("jpeg", { quality: 90 }); } } diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts index ed209ee8470..44a2daf379a 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts @@ -21,10 +21,14 @@ export class Asset { } clone() { - const clonedAsset = new Asset(structuredClone(this.props)); - clonedAsset.outputStrategy = this.outputStrategy; - clonedAsset.contentsReader = this.contentsReader; - return clonedAsset; + return this.withProps(structuredClone(this.props)); + } + + withProps(props: Partial) { + const newAsset = new Asset({ ...this.props, ...props }); + newAsset.contentsReader = this.contentsReader; + newAsset.outputStrategy = this.outputStrategy; + return newAsset; } getId() { @@ -39,9 +43,8 @@ export class Asset { getKey() { return this.props.key; } - async getSize() { - const buffer = await this.getContents(); - return buffer.length; + getSize() { + return this.props.size; } getContentType() { return this.props.contentType; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts index b0b17f62685..5ef161179f8 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts @@ -1,6 +1,6 @@ import { Request } from "@webiny/handler/types"; import { AssetRequestResolver } from "./abstractions/AssetRequestResolver"; -import { AssetRequest } from "./AssetRequest"; +import { AssetRequest, AssetRequestOptions } from "./AssetRequest"; export class FilesAssetRequestResolver implements AssetRequestResolver { async resolve(request: Request): Promise { @@ -15,15 +15,21 @@ export class FilesAssetRequestResolver implements AssetRequestResolver { // Example: { '*': '/files/65722cb5c7824a0008d05963/image-48.jpg' }, const path = params["*"]; + const options: AssetRequestOptions = { + ...query, + original: "original" in query + }; + + if (query.width) { + options.width = parseInt(query.width); + } + return new AssetRequest({ key: decodeURI(path).replace("/files/", ""), context: { url: request.url }, - options: { - ...query, - width: query.width ? parseInt(query.width) : undefined - } + options }); } } diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts index fbd330330a7..39d17f83d52 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts @@ -12,6 +12,7 @@ export class TransformationAssetProcessor implements AssetProcessor { // If the `original` image was requested, we skip all transformations. if (original) { + console.log("Skip transformations; original asset was requested."); return asset; } diff --git a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts index 9a50159a9be..6e09b6394c9 100644 --- a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts +++ b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts @@ -146,6 +146,7 @@ export const setupAssetDelivery = (params: AssetDeliveryParams) => { ); // Get reply object (runs the output strategy under the hood). + console.log(`Output asset (size: ${processedAsset.getSize()} bytes).`); return outputAsset(reply, processedAsset); }, { override: true } diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts index 67b5cd28260..6a9408e913a 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts @@ -17,7 +17,13 @@ export const createElasticsearchSort = (params: Params): esSort => { const { sort, modelFields, plugins } = params; if (!sort || sort.length === 0) { - return []; + return [ + { + ["id.keyword"]: { + order: "asc" + } + } + ]; } const searchPlugins = createSearchPluginList({ diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts index 2ae39e96bd2..9d1c76fc93c 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -1020,7 +1020,7 @@ export const createEntriesStorageOperations = ( * Although we do not need a cursor here, we will use it as such to keep it standardized. * Number is simply encoded. */ - const cursor = totalCount > start + limit ? encodeCursor(`${start + limit}`) : null; + const cursor = encodeCursor(`${start + limit}`); return { hasMoreItems, totalCount, diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index 7a91f9d558b..24005cc9e5f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -2,6 +2,7 @@ import models from "./mocks/contentModels"; import { CmsEntry, CmsGroup, CmsModel } from "~/types"; import { useCategoryManageHandler } from "../testHelpers/useCategoryManageHandler"; import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; +import { createMockCmsEntry } from "~tests/helpers/createMockCmsEntry"; const manageOpts = { path: "manage/en-US" @@ -90,7 +91,7 @@ describe("Content Entry Meta Field", () => { it("storage operations - should have meta field data in the retrieved record", async () => { const { model } = await setup(); const entryId = generateAlphaNumericLowerCaseId(8); - const entry: CmsEntry = { + const entry = createMockCmsEntry({ id: `${entryId}#0001`, entryId, version: 1, @@ -117,7 +118,7 @@ describe("Content Entry Meta Field", () => { status: "draft", webinyVersion: "5.27.0", meta: createMetaData() - }; + }); const createdRecord = await storageOperations.entries.create(model, { entry, @@ -177,7 +178,7 @@ describe("Content Entry Meta Field", () => { meta: createMetaData() } ], - cursor: null, + cursor: expect.any(String), totalCount: 1 }); diff --git a/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts b/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts new file mode 100644 index 00000000000..a4288596865 --- /dev/null +++ b/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts @@ -0,0 +1,7 @@ +import { CmsEntry } from "~/types"; + +export const createMockCmsEntry = (input: Partial): T => { + return { + ...input + } as T; +}; diff --git a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts index bfab26d276f..6920c5331c5 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts @@ -107,7 +107,7 @@ describe("Entries storage operations", () => { expect(result.items).toHaveLength(amount); expect(result).toMatchObject({ - cursor: null, + cursor: expect.any(String), hasMoreItems: false, totalCount: amount }); diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts index e2ce7c2ea57..2bde41b247c 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts @@ -48,6 +48,8 @@ import { import { sortItems } from "@webiny/db-dynamodb/utils/sort"; import { PageDynamoDbElasticsearchFieldPlugin } from "~/plugins/definitions/PageDynamoDbElasticsearchFieldPlugin"; import { getClean, put } from "@webiny/db-dynamodb"; +import { shouldIgnoreEsResponseError } from "~/operations/pages/shouldIgnoreEsResponseError"; +import { logIgnoredEsResponseError } from "~/operations/pages/logIgnoredEsResponseError"; /** * This function removes attributes that were once present in the Page record, which we no longer need. @@ -793,7 +795,11 @@ export const createPageStorageOperations = ( * Do not throw the error if Elasticsearch index does not exist. * In some CRUDs we try to get list of pages but index was not created yet. */ - if (ex.message === "index_not_found_exception") { + if (shouldIgnoreEsResponseError(ex)) { + logIgnoredEsResponseError({ + error: ex, + indexName: esConfig.index + }); return { items: [], meta: { @@ -884,6 +890,13 @@ export const createPageStorageOperations = ( } return tags.buckets.map(item => item.key); } catch (ex) { + if (shouldIgnoreEsResponseError(ex)) { + logIgnoredEsResponseError({ + error: ex, + indexName: esConfig.index + }); + return []; + } throw new WebinyError( ex.message || "Could not list tags by given parameters.", ex.code || "LIST_TAGS_ERROR", diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts new file mode 100644 index 00000000000..a35151ea4d6 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts @@ -0,0 +1,23 @@ +import WebinyError from "@webiny/error"; + +interface LogIgnoredElasticsearchExceptionParams { + error: WebinyError; + indexName: string; +} + +export const logIgnoredEsResponseError = (params: LogIgnoredElasticsearchExceptionParams) => { + const { error, indexName } = params; + if (process.env.DEBUG !== "true") { + return; + } + console.log(`Ignoring Elasticsearch response error: ${error.message}`, { + usedIndexName: indexName, + error: { + ...error, + message: error.message, + code: error.code, + data: error.data, + stack: error.stack + } + }); +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts new file mode 100644 index 00000000000..9455112cf02 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts @@ -0,0 +1,10 @@ +import WebinyError from "@webiny/error"; + +const IGNORED_ES_SEARCH_EXCEPTIONS = [ + "index_not_found_exception", + "search_phase_execution_exception" +]; + +export const shouldIgnoreEsResponseError = (error: WebinyError) => { + return IGNORED_ES_SEARCH_EXCEPTIONS.includes(error.message); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx index 99e6d6755b1..15c2cf3b435 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx @@ -27,7 +27,7 @@ export const DeleteEntry = () => { return ( } - label={"Delete"} + label={"Delete entry"} onAction={() => showConfirmationDialog({ onAccept: navigateBacktoList diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/PublishEntryRevisionListItem.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/PublishEntryRevisionListItem.tsx index b132c47284d..1fe74526577 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/PublishEntryRevisionListItem.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/PublishEntryRevisionListItem.tsx @@ -13,7 +13,7 @@ const PublishEntryRevisionListItemComponent = () => { } /> - {t`Publish`} + {t`Publish revision`} ); }; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx index 78b1fdffc3d..f7cfcd57626 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx @@ -34,7 +34,7 @@ const t = i18n.ns("app-headless-cms/admin/plugins/content-details/content-revisi const primaryColor = css({ color: "var(--mdc-theme-primary)" }); const revisionsMenu = css({ - width: 250, + width: 300, right: -105, left: "auto !important" }); @@ -123,7 +123,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`New from current`} + {t`New revision from current`} )} @@ -137,7 +137,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`Edit`} + {t`Edit revision`} )} @@ -155,7 +155,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`Unpublish`} + {t`Unpublish revision`} )} @@ -166,7 +166,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t` Delete`} + {t` Delete revision`} )}