feat(): Translation statistics (#14299)

* chore(): Translation statistics

* chore(): improve statistics performances

* add end point to get statistics

* add tests

* Create spicy-games-unite.md

* feat(): add material and fix tests

* feat(): add translatable api

* feat(): add translatable api

* fix tests

* fix tests

* fix tests

* feedback
This commit is contained in:
Adrien de Peretti
2025-12-15 14:11:49 +01:00
committed by GitHub
parent 0f1566c644
commit ba6ed8d9dd
20 changed files with 1196 additions and 2 deletions

View File

@@ -21,3 +21,18 @@ export interface AdminTranslationsListParams
*/
locale_code?: string | string[]
}
/**
* Request body for translation statistics endpoint.
*/
export interface AdminTranslationStatisticsParams {
/**
* The locales to check translations for (e.g., ["en-US", "fr-FR"]).
*/
locales: string[]
/**
* The entity types to get statistics for (e.g., ["product", "product_variant"]).
*/
entity_types: string[]
}

View File

@@ -33,3 +33,58 @@ export interface AdminTranslationsBatchResponse {
deleted: boolean
}
}
/**
* Statistics for a specific locale.
*/
export interface AdminTranslationLocaleStatistics {
/**
* Expected number of translated fields.
*/
expected: number
/**
* Actual number of translated fields.
*/
translated: number
/**
* Number of missing translations.
*/
missing: number
}
/**
* Statistics for an entity type.
*/
export interface AdminTranslationEntityStatistics
extends AdminTranslationLocaleStatistics {
/**
* Breakdown of statistics by locale.
*/
by_locale: Record<string, AdminTranslationLocaleStatistics>
}
/**
* Response for translation statistics endpoint.
*/
export interface AdminTranslationStatisticsResponse {
/**
* Statistics by entity type.
*/
statistics: Record<string, AdminTranslationEntityStatistics>
}
/**
* Response for translation settings endpoint.
*/
export interface AdminTranslationSettingsResponse {
/**
* A mapping of entity types to their translatable field names.
*
* @example
* {
* "product": ["title", "description", "subtitle", "status"],
* "product_variant": ["title", "material"]
* }
*/
translatable_fields: Record<string, string[]>
}

View File

@@ -132,3 +132,55 @@ export interface FilterableTranslationProps
*/
locale_code?: string | string[] | OperatorMap<string>
}
/**
* Input for getStatistics method.
*/
export interface TranslationStatisticsInput {
/**
* Locales to check translations for (e.g., ["en-US", "fr-FR"]).
*/
locales: string[]
/**
* Entity types with their total counts.
* Key is the entity type (e.g., "product"), value contains the count of entities.
*/
entities: Record<string, { count: number }>
}
/**
* Statistics for a specific locale.
*/
export interface LocaleStatistics {
/**
* Expected number of translated fields.
*/
expected: number
/**
* Actual number of translated fields (non-null, non-empty).
*/
translated: number
/**
* Number of missing translations (expected - translated).
*/
missing: number
}
/**
* Statistics for an entity type.
*/
export interface EntityTypeStatistics extends LocaleStatistics {
/**
* Breakdown of statistics by locale.
*/
by_locale: Record<string, LocaleStatistics>
}
/**
* Output of getStatistics method.
* Maps entity types to their translation statistics.
*/
export type TranslationStatisticsOutput = Record<string, EntityTypeStatistics>

View File

@@ -7,6 +7,8 @@ import {
FilterableTranslationProps,
LocaleDTO,
TranslationDTO,
TranslationStatisticsInput,
TranslationStatisticsOutput,
} from "./common"
import {
CreateLocaleDTO,
@@ -291,4 +293,57 @@ export interface ITranslationModuleService extends IModuleService {
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method retrieves translation statistics for the given entities and locales.
* It counts translated fields at a granular level, providing both aggregated
* and per-locale breakdowns.
*
* @param {TranslationStatisticsInput} input - The entities and locales to check.
* @param {Context} sharedContext
* @returns {Promise<TranslationStatisticsOutput>} Statistics by entity type.
*
* @example
* const stats = await translationService.getStatistics({
* locales: ["en-US", "fr-FR"],
* entities: {
* product: { count: 2 },
* product_variant: { count: 5 }
* }
* })
* // Returns:
* // {
* // product: {
* // expected: 20, // 2 products × 5 fields × 2 locales
* // translated: 15,
* // missing: 5,
* // by_locale: {
* // "en-US": { expected: 10, translated: 8, missing: 2 },
* // "fr-FR": { expected: 10, translated: 7, missing: 3 }
* // }
* // }
* // }
*/
getStatistics(
input: TranslationStatisticsInput,
sharedContext?: Context
): Promise<TranslationStatisticsOutput>
/**
* This method retrieves the translatable fields configuration.
* Returns a mapping of entity types to their translatable field names.
*
* @param {string} entityType - Optional entity type to filter by. If not provided, returns all.
* @returns {Record<string, string[]>} A mapping of entity types to their translatable fields.
*
* @example
* // Get all translatable fields
* const allFields = translationService.getTranslatableFields()
* // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] }
*
* // Get fields for a specific entity type
* const productFields = translationService.getTranslatableFields("product")
* // Returns: { product: ["title", "description", "subtitle", "status"] }
*/
getTranslatableFields(entityType?: string): Record<string, string[]>
}

View File

@@ -6,6 +6,7 @@ import {
import {
AdminBatchTranslations,
AdminGetTranslationsParams,
AdminTranslationStatistics,
} from "./validators"
import * as QueryConfig from "./query-config"
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils"
@@ -29,4 +30,14 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
},
middlewares: [validateAndTransformBody(AdminBatchTranslations)],
},
{
method: ["GET"],
matcher: "/admin/translations/statistics",
middlewares: [validateAndTransformQuery(AdminTranslationStatistics, {})],
},
{
method: ["GET"],
matcher: "/admin/translations/settings",
middlewares: [],
},
]

View File

@@ -0,0 +1,29 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types"
import {
defineFileConfig,
FeatureFlag,
Modules,
} from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../../feature-flags/translation"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse>
) => {
const translationService = req.scope.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
const translatable_fields = translationService.getTranslatableFields()
res.json({
translatable_fields,
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -0,0 +1,75 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
Modules,
promiseAll,
} from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../../feature-flags/translation"
export const GET = async (
req: AuthenticatedMedusaRequest<
unknown,
HttpTypes.AdminTranslationStatisticsParams
>,
res: MedusaResponse<HttpTypes.AdminTranslationStatisticsResponse>
) => {
const translationService = req.scope.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { locales, entity_types } = req.validatedQuery
// Fetch counts for each entity type in parallel
const entityCounts = await promiseAll(
entity_types.map(async (entityType) => {
const { metadata } = await query
.graph(
{
entity: entityType,
fields: ["id"],
pagination: { take: 1, skip: 0 },
},
{
throwIfKeyNotFound: false,
cache: { enable: true },
}
)
.catch((e) => {
const normalizedMessage = e.message.toLowerCase()
if (
normalizedMessage.includes("service with alias") &&
normalizedMessage.includes("was not found")
) {
return { metadata: { count: 0 } }
}
throw e
})
return { entityType, count: metadata?.count ?? 0 }
})
)
const entities: Record<string, { count: number }> = {}
for (const { entityType, count } of entityCounts) {
entities[entityType] = { count }
}
const statistics = await translationService.getStatistics({
locales,
entities,
})
return res.json({
statistics,
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -48,3 +48,19 @@ export const AdminBatchTranslations = createBatchBody(
AdminCreateTranslation,
AdminUpdateTranslation
)
export type AdminTranslationStatisticsType = z.infer<
typeof AdminTranslationStatistics
>
export const AdminTranslationStatistics = z
.object({
locales: z.union([z.string(), z.array(z.string())]),
entity_types: z.union([z.string(), z.array(z.string())]),
})
.transform((data) => ({
// Normalize to arrays for consistent handling
locales: Array.isArray(data.locales) ? data.locales : [data.locales],
entity_types: Array.isArray(data.entity_types)
? data.entity_types
: [data.entity_types],
}))

View File

@@ -646,6 +646,330 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
})
})
})
describe("Statistics", () => {
describe("getStatistics", () => {
it("should return statistics for a single entity type and locale", async () => {
// Create translations for 2 products with some fields filled
// Product has 4 translatable fields: title, description, subtitle, status
await service.createTranslations([
{
reference_id: "prod_stat_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product 1",
description: "Description 1",
// subtitle and status are missing
},
},
{
reference_id: "prod_stat_2",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product 2",
description: "Description 2",
subtitle: "Subtitle 2",
status: "Active",
},
},
])
const stats = await service.getStatistics({
locales: ["en-US"],
entities: {
product: { count: 2 },
},
})
// Expected: 2 products × 5 fields × 1 locale = 10
// Translated: prod_1 has 2, prod_2 has 4 = 6
expect(stats.product).toEqual({
expected: 10,
translated: 6,
missing: 4,
by_locale: {
"en-US": {
expected: 10,
translated: 6,
missing: 4,
},
},
})
})
it("should return statistics for multiple locales", async () => {
await service.createTranslations([
{
reference_id: "prod_multi_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product 1 EN",
description: "Description EN",
},
},
{
reference_id: "prod_multi_1",
reference: "product",
locale_code: "fr-FR",
translations: {
title: "Produit 1 FR",
// only title translated for French
},
},
])
const stats = await service.getStatistics({
locales: ["en-US", "fr-FR"],
entities: {
product: { count: 1 },
},
})
// Expected per locale: 1 product × 5 fields = 5
// Total expected: 5 × 2 locales = 10
expect(stats.product.expected).toEqual(10)
expect(stats.product.translated).toEqual(3) // 2 EN + 1 FR
expect(stats.product.missing).toEqual(7)
expect(stats.product.by_locale["en-US"]).toEqual({
expected: 5,
translated: 2,
missing: 3,
})
expect(stats.product.by_locale["fr-FR"]).toEqual({
expected: 5,
translated: 1,
missing: 4,
})
})
it("should return statistics for multiple entity types", async () => {
await service.createTranslations([
{
reference_id: "prod_type_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product Title",
description: "Product Description",
subtitle: "Product Subtitle",
status: "Active",
material: "Product Material",
},
},
{
reference_id: "var_type_1",
reference: "product_variant",
locale_code: "en-US",
translations: {
title: "Variant Title",
// material missing
},
},
])
const stats = await service.getStatistics({
locales: ["en-US"],
entities: {
product: { count: 1 },
product_variant: { count: 1 },
},
})
// Product: 1 × 5 fields = 5 expected, 5 translated
expect(stats.product).toEqual({
expected: 5,
translated: 5,
missing: 0,
by_locale: {
"en-US": { expected: 5, translated: 5, missing: 0 },
},
})
// Variant: 1 × 2 fields = 2 expected, 1 translated
expect(stats.product_variant).toEqual({
expected: 2,
translated: 1,
missing: 1,
by_locale: {
"en-US": { expected: 2, translated: 1, missing: 1 },
},
})
})
it("should return zeros for entity types not in config", async () => {
const stats = await service.getStatistics({
locales: ["en-US"],
entities: {
unknown_entity: { count: 10 },
},
})
expect(stats.unknown_entity).toEqual({
expected: 0,
translated: 0,
missing: 0,
by_locale: {
"en-US": { expected: 0, translated: 0, missing: 0 },
},
})
})
it("should return all missing when no translations exist", async () => {
const stats = await service.getStatistics({
locales: ["en-US", "fr-FR"],
entities: {
product: { count: 5 },
},
})
// 5 products × 5 fields × 2 locales = 50 expected, 0 translated
expect(stats.product).toEqual({
expected: 50,
translated: 0,
missing: 50,
by_locale: {
"en-US": { expected: 25, translated: 0, missing: 25 },
"fr-FR": { expected: 25, translated: 0, missing: 25 },
},
})
})
it("should ignore empty string and null values in translations", async () => {
await service.createTranslations([
{
reference_id: "prod_empty_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Valid Title",
description: "", // empty string should not count
subtitle: "Valid Subtitle",
},
},
])
const stats = await service.getStatistics({
locales: ["en-US"],
entities: {
product: { count: 1 },
},
})
// Only title, subtitle and material should count (3), not empty description
expect(stats.product.translated).toEqual(2)
expect(stats.product.missing).toEqual(3)
})
it("should normalize locale codes", async () => {
await service.createTranslations([
{
reference_id: "prod_norm_1",
reference: "product",
locale_code: "en-us",
translations: {
title: "Product Title",
},
},
])
const stats = await service.getStatistics({
locales: ["EN-US"],
entities: {
product: { count: 1 },
},
})
expect(stats.product.translated).toEqual(1)
})
it("should throw error when no locales provided", async () => {
const error = await service
.getStatistics({
locales: [],
entities: { product: { count: 1 } },
})
.catch((e) => e)
expect(error.message).toContain(
"At least one locale must be provided"
)
})
it("should throw error when no entities provided", async () => {
const error = await service
.getStatistics({
locales: ["en-US"],
entities: {},
})
.catch((e) => e)
expect(error.message).toContain(
"At least one entity type must be provided"
)
})
it("should handle large entity counts correctly", async () => {
// This tests that the expected calculation works with large numbers
// without actually creating that many translations
const stats = await service.getStatistics({
locales: ["en-US", "fr-FR", "de-DE"],
entities: {
product: { count: 10000 },
product_variant: { count: 50000 },
},
})
// Product: 10000 × 5 fields × 3 locales = 150000
expect(stats.product.expected).toEqual(150000)
expect(stats.product.translated).toEqual(0)
expect(stats.product.missing).toEqual(150000)
// Variant: 50000 × 2 fields × 3 locales = 300000
expect(stats.product_variant.expected).toEqual(300000)
expect(stats.product_variant.translated).toEqual(0)
expect(stats.product_variant.missing).toEqual(300000)
})
it("should update statistics after translation is updated", async () => {
const created = await service.createTranslations({
reference_id: "prod_update_stat_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product Title",
// only 1 of 5 fields
},
})
let stats = await service.getStatistics({
locales: ["en-US"],
entities: { product: { count: 1 } },
})
expect(stats.product.translated).toEqual(1)
await service.updateTranslations({
id: created.id,
translations: {
title: "Product Title",
description: "Product Description",
subtitle: "Product Subtitle",
},
})
stats = await service.getStatistics({
locales: ["en-US"],
entities: { product: { count: 1 } },
})
expect(stats.product.translated).toEqual(3)
expect(stats.product.missing).toEqual(2)
})
})
})
})
},
})

View File

@@ -1,10 +1,11 @@
import TranslationModuleService from "@services/translation-module"
import loadDefaults from "./loaders/defaults"
import loadConfig from "./loaders/config"
import { Module } from "@medusajs/framework/utils"
export const TRANSLATION_MODULE = "translation"
export default Module(TRANSLATION_MODULE, {
service: TranslationModuleService,
loaders: [loadDefaults],
loaders: [loadDefaults, loadConfig],
})

View File

@@ -0,0 +1,52 @@
import { LoaderOptions } from "@medusajs/framework/types"
import {
PRODUCT_TRANSLATABLE_FIELDS,
PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
} from "../utils/translatable-fields"
import { asValue } from "awilix"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
export default async ({
container,
options,
}: LoaderOptions<{
expandedTranslatableFields: { [key: string]: string[] }
}>): Promise<void> => {
const { expandedTranslatableFields } = options ?? {}
const { product, productVariant, ...others } =
expandedTranslatableFields ?? {}
const translatableFieldsConfig: Record<string, string[]> = {
product: PRODUCT_TRANSLATABLE_FIELDS,
product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
}
if (product) {
const translatableFields = new Set([
...PRODUCT_TRANSLATABLE_FIELDS,
...product,
])
translatableFieldsConfig.product = Array.from(translatableFields)
}
if (productVariant) {
const translatableFields = new Set([
...PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
...productVariant,
])
translatableFieldsConfig.product_variant = Array.from(translatableFields)
}
if (others) {
Object.entries(others).forEach(([key, value]) => {
const translatableFields = new Set([...value])
translatableFieldsConfig[key] = Array.from(translatableFields)
})
}
container.register(
TRANSLATABLE_FIELDS_CONFIG_KEY,
asValue(translatableFieldsConfig)
)
}

View File

@@ -149,6 +149,16 @@
"nullable": false,
"mappedType": "json"
},
"translated_field_count": {
"name": "translated_field_count",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "0",
"mappedType": "integer"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",

View File

@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20251215083927 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "translation" add column if not exists "translated_field_count" integer not null default 0;`);
}
override async down(): Promise<void> {
this.addSql(`alter table if exists "translation" drop column if exists "translated_field_count";`);
}
}

View File

@@ -6,7 +6,8 @@ const Translation = model
reference_id: model.text().searchable(),
reference: model.text().searchable(), // e.g., "product", "product_variant", "product_category"
locale_code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
translations: model.json(), // JSON object containing translated fields, e.g., { "title": "...", "description": "..." }
translations: model.json(),
translated_field_count: model.number().default(0), // Precomputed count of translated fields for performance
})
.indexes([
{

View File

@@ -10,20 +10,25 @@ import {
ModulesSdkTypes,
TranslationTypes,
} from "@medusajs/framework/types"
import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql"
import {
EmitEvents,
InjectManager,
MedusaContext,
MedusaError,
MedusaService,
normalizeLocale,
} from "@medusajs/framework/utils"
import Locale from "@models/locale"
import Translation from "@models/translation"
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
export default class TranslationModuleService
@@ -48,15 +53,19 @@ export default class TranslationModuleService
typeof Locale
>
private readonly translatableFieldsConfig_: Record<string, string[]>
constructor({
baseRepository,
translationService,
localeService,
translatableFieldsConfig,
}: InjectedDependencies) {
super(...arguments)
this.baseRepository_ = baseRepository
this.translationService_ = translationService
this.localeService_ = localeService
this.translatableFieldsConfig_ = translatableFieldsConfig
}
static prepareFilters(
@@ -177,6 +186,10 @@ export default class TranslationModuleService
const normalizedData = dataArray.map((translation) => ({
...translation,
locale_code: normalizeLocale(translation.locale_code),
translated_field_count: computeTranslatedFieldCount(
translation.translations as Record<string, unknown>,
this.translatableFieldsConfig_[translation.reference]
),
}))
const createdTranslations = await this.translationService_.create(
@@ -190,4 +203,193 @@ export default class TranslationModuleService
return Array.isArray(data) ? serialized : serialized[0]
}
// @ts-expect-error
updateTranslations(
data: TranslationTypes.UpdateTranslationDTO,
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO>
// @ts-expect-error
updateTranslations(
data: TranslationTypes.UpdateTranslationDTO[],
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateTranslations(
data:
| TranslationTypes.UpdateTranslationDTO
| TranslationTypes.UpdateTranslationDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
const updatesWithTranslations = dataArray.filter((d) => d.translations)
if (updatesWithTranslations.length) {
const idsNeedingReference = updatesWithTranslations
.filter((d) => !d.reference)
.map((d) => d.id)
let referenceMap: Record<string, string> = {}
if (idsNeedingReference.length) {
const existingTranslations = await this.translationService_.list(
{ id: idsNeedingReference },
{ select: ["id", "reference"] },
sharedContext
)
referenceMap = Object.fromEntries(
existingTranslations.map((t) => [t.id, t.reference])
)
}
for (const update of dataArray) {
if (update.translations) {
const reference = update.reference || referenceMap[update.id]
;(
update as TranslationTypes.UpdateTranslationDTO & {
translated_field_count: number
}
).translated_field_count = computeTranslatedFieldCount(
update.translations as Record<string, unknown>,
this.translatableFieldsConfig_[reference] || []
)
}
}
}
const updatedTranslations = await this.translationService_.update(
dataArray,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(updatedTranslations)
return Array.isArray(data) ? serialized : serialized[0]
}
getTranslatableFields(entityType?: string): Record<string, string[]> {
if (entityType) {
return { [entityType]: this.translatableFieldsConfig_[entityType] }
}
return this.translatableFieldsConfig_
}
@InjectManager()
async getStatistics(
input: TranslationTypes.TranslationStatisticsInput,
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationStatisticsOutput> {
const { locales, entities } = input
if (!locales || !locales.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"At least one locale must be provided"
)
}
if (!entities || !Object.keys(entities).length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"At least one entity type must be provided"
)
}
const normalizedLocales = locales.map(normalizeLocale)
const manager = (sharedContext.transactionManager ??
sharedContext.manager) as SqlEntityManager
const knex = manager.getKnex()
const result: TranslationTypes.TranslationStatisticsOutput = {}
const entityTypes: string[] = []
for (const entityType of Object.keys(entities)) {
const translatableFields = this.translatableFieldsConfig_[entityType]
if (!translatableFields || translatableFields.length === 0) {
result[entityType] = {
expected: 0,
translated: 0,
missing: 0,
by_locale: Object.fromEntries(
normalizedLocales.map((locale) => [
locale,
{ expected: 0, translated: 0, missing: 0 },
])
),
}
} else {
entityTypes.push(entityType)
}
}
if (!entityTypes.length) {
return result
}
const { rows } = await knex.raw(
`
SELECT
reference,
locale_code,
COALESCE(SUM(translated_field_count), 0)::int AS translated_field_count
FROM translation
WHERE reference = ANY(?)
AND locale_code = ANY(?)
AND deleted_at IS NULL
GROUP BY reference, locale_code
`,
[entityTypes, normalizedLocales]
)
for (const entityType of entityTypes) {
const translatableFields = this.translatableFieldsConfig_[entityType]
const fieldsPerEntity = translatableFields.length
const entityCount = entities[entityType].count
const expectedPerLocale = entityCount * fieldsPerEntity
result[entityType] = {
expected: expectedPerLocale * normalizedLocales.length,
translated: 0,
missing: expectedPerLocale * normalizedLocales.length,
by_locale: Object.fromEntries(
normalizedLocales.map((locale) => [
locale,
{
expected: expectedPerLocale,
translated: 0,
missing: expectedPerLocale,
},
])
),
}
}
for (const row of rows) {
const entityType = row.reference
const localeCode = row.locale_code
const translatedCount = parseInt(row.translated_field_count, 10) || 0
result[entityType].by_locale[localeCode].translated = translatedCount
result[entityType].by_locale[localeCode].missing =
result[entityType].by_locale[localeCode].expected - translatedCount
result[entityType].translated += translatedCount
}
for (const entityType of entityTypes) {
result[entityType].missing =
result[entityType].expected - result[entityType].translated
}
return result
}
}

View File

@@ -0,0 +1,23 @@
/**
* Computes the count of translated fields based on the translatable fields configuration.
* Only counts fields that are:
* 1. In the translatableFields array for the entity type
* 2. Have a non-null, non-empty value in the translations object
*
* @param translations - The translations JSON object from the translation record
* @param translatableFields - Array of field names that are translatable for this entity type
* @returns The count of translated fields
*/
export function computeTranslatedFieldCount(
translations: Record<string, unknown> | undefined | null,
translatableFields: string[] | undefined | null
): number {
if (!translations || !translatableFields?.length) {
return 0
}
return translatableFields.filter((field) => {
const value = translations[field]
return value != null && value !== "" && value !== "null"
}).length
}

View File

@@ -0,0 +1 @@
export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig"

View File

@@ -0,0 +1,9 @@
export const PRODUCT_TRANSLATABLE_FIELDS = [
"title",
"description",
"material",
"subtitle",
"status",
]
export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]