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
@@ -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
}
}