UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

386 lines (384 loc) 17.7 kB
import { useLogger } from "../logger/index.js"; import emitter_default from "../emitter.js"; import { transaction } from "./transaction.js"; import { ItemsService } from "../services/items.js"; import { RelationsService } from "../services/relations.js"; import { getSchema } from "./get-schema.js"; import { FieldsService } from "../services/fields.js"; import { CollectionsService } from "../services/collections.js"; import { cloneFields, validateFieldsEligibility } from "./translations-shared.js"; import { dbSafeIdentifierSchema } from "./translations-validation.js"; import { InvalidPayloadError } from "@directus/errors"; import { fromZodError } from "zod-validation-error"; import { z as z$1 } from "zod"; //#region src/utils/generate-translations.ts const logger = useLogger(); function findDuplicate(names) { return names.find((name, index) => names.indexOf(name) !== index); } function buildTranslationsAliasField(languagesFields) { return { field: "translations", type: "alias", meta: { interface: "translations", special: ["translations"], options: { languageField: languagesFields["name"] ? "name" : null, languageDirectionField: languagesFields["direction"] ? "direction" : null } } }; } const GenerateTranslationsInput = z$1.object({ collection: dbSafeIdentifierSchema, fields: z$1.array(dbSafeIdentifierSchema).min(1), translationsCollection: dbSafeIdentifierSchema.optional(), languagesCollection: dbSafeIdentifierSchema.optional(), parentFkField: dbSafeIdentifierSchema.optional(), languageFkField: dbSafeIdentifierSchema.optional(), createLanguagesCollection: z$1.boolean().optional().default(true), seedLanguages: z$1.boolean().optional().default(true) }); async function generateTranslations(input, options) { const parseResult = GenerateTranslationsInput.safeParse(input); if (!parseResult.success) throw new InvalidPayloadError({ reason: fromZodError(parseResult.error).message }); if (!options.knex) throw new Error("Knex instance is required to generate translations"); const { collection, fields: fieldNames, translationsCollection = `${parseResult.data.collection}_translations`, languagesCollection = "languages", parentFkField, languageFkField, createLanguagesCollection = true, seedLanguages = true } = parseResult.data; const nestedActionEvents = []; const mutationOptions = { autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params), bypassLimits: true }; const result = await transaction(options.knex, async (trx) => { let currentSchema = options.schema ?? await getSchema({ database: trx, bypassCache: true }); const getServiceOptions = (schema) => ({ accountability: options.accountability, knex: trx, schema }); const sourceCollection = currentSchema.collections[collection]; if (!sourceCollection) throw new InvalidPayloadError({ reason: `Collection "${collection}" does not exist` }); const duplicateRequestedField = findDuplicate(fieldNames); if (duplicateRequestedField) throw new InvalidPayloadError({ reason: `Field "${duplicateRequestedField}" would be created multiple times on "${translationsCollection}"` }); const readSourceFields = async () => { const sourceFieldsService = new FieldsService(getServiceOptions(currentSchema)); return Promise.all(fieldNames.map(async (fieldName) => await sourceFieldsService.readOne(collection, fieldName))); }; const validateExistingTranslationsCollection = async () => { const invalidCollectionError = new InvalidPayloadError({ reason: `Collection "${translationsCollection}" is not a valid translations collection for "${collection}"` }); let parentRelations = currentSchema.relations.filter((relation) => relation.collection === translationsCollection && relation.related_collection === collection && (relation.meta?.one_collection === null || relation.meta?.one_collection === collection) && (relation.meta?.many_collection === null || relation.meta?.many_collection === translationsCollection) && typeof relation.meta?.one_field === "string" && relation.meta.one_field.length > 0 && typeof relation.meta?.junction_field === "string" && relation.meta.junction_field.length > 0); if (parentRelations.length === 0) { const orphanedRelations = currentSchema.relations.filter((relation) => relation.collection === translationsCollection && relation.related_collection === collection && (relation.meta?.one_collection === null || relation.meta?.one_collection === collection) && (relation.meta?.many_collection === null || relation.meta?.many_collection === translationsCollection) && relation.meta?.one_field === null && typeof relation.meta?.junction_field === "string" && relation.meta.junction_field.length > 0); if (orphanedRelations.length === 1) { const orphanedRelation = orphanedRelations[0]; const languageFkField$1 = orphanedRelation.meta.junction_field; const orphanedLanguageRelation = currentSchema.relations.find((relation) => relation.collection === translationsCollection && relation.field === languageFkField$1 && relation.meta?.junction_field === orphanedRelation.field && typeof relation.related_collection === "string" && relation.related_collection.length > 0); if (!orphanedLanguageRelation) throw invalidCollectionError; const resolvedLangsCollection = orphanedLanguageRelation.related_collection; const langsFields = currentSchema.collections[resolvedLangsCollection]?.fields ?? {}; await new FieldsService(getServiceOptions(currentSchema)).createField(collection, buildTranslationsAliasField(langsFields), void 0, mutationOptions); await new ItemsService("directus_relations", { knex: trx, schema: currentSchema }).updateOne(orphanedRelation.meta.id, { one_field: "translations" }, mutationOptions); currentSchema = await getSchema({ database: trx, bypassCache: true }); parentRelations = currentSchema.relations.filter((relation) => relation.collection === translationsCollection && relation.related_collection === collection && relation.meta?.one_field === "translations" && typeof relation.meta?.junction_field === "string" && relation.meta.junction_field.length > 0); } } if (parentRelations.length !== 1) throw invalidCollectionError; const parentRelation = parentRelations[0]; const translationField = parentRelation.meta.one_field; const sourceTranslationField = currentSchema.collections[collection]?.fields[translationField]; const sourceTranslationSpecials = Array.isArray(sourceTranslationField?.special) ? sourceTranslationField.special : []; if (!sourceTranslationField || !sourceTranslationSpecials.includes("translations")) throw invalidCollectionError; const languageForeignKeyField = parentRelation.meta.junction_field; const languageRelation = currentSchema.relations.find((relation) => relation.collection === translationsCollection && relation.field === languageForeignKeyField && relation.meta?.junction_field === parentRelation.field && typeof relation.related_collection === "string" && relation.related_collection.length > 0); if (!languageRelation) throw invalidCollectionError; return { parentRelation, languageRelation }; }; const existingTranslationsCollection = currentSchema.collections[translationsCollection]; if (existingTranslationsCollection) { const { parentRelation, languageRelation } = await validateExistingTranslationsCollection(); const newFieldNames = fieldNames.filter((fieldName) => { if (sourceCollection.fields[fieldName] === void 0) throw new InvalidPayloadError({ reason: `Field "${fieldName}" does not exist on "${collection}"` }); return existingTranslationsCollection.fields[fieldName] === void 0; }); if (newFieldNames.length > 0) { const sourceFieldsService = new FieldsService(getServiceOptions(currentSchema)); const sourceFields$1 = await Promise.all(newFieldNames.map(async (fieldName) => await sourceFieldsService.readOne(collection, fieldName))); validateFieldsEligibility(sourceFields$1); const clonedFields$1 = cloneFields({ fields: newFieldNames, sourceFields: sourceFields$1 }); const allParentFields = await trx.from("directus_fields").where({ collection }).select("field", "sort"); const parentSortMap = new Map(allParentFields.map((f) => [f.field, f.sort ?? Infinity])); const existingTransFields = await trx.from("directus_fields").where({ collection: translationsCollection }).select("field", "sort"); const systemFieldNames = new Set([ "id", parentRelation.field, languageRelation.field ]); const maxSystemSort = existingTransFields.filter((f) => systemFieldNames.has(f.field)).reduce((max, f) => Math.max(max, f.sort ?? 0), 0); const existingContentFields = existingTransFields.filter((f) => !systemFieldNames.has(f.field)); const allContentFieldNames = [...existingContentFields.map((f) => f.field), ...newFieldNames]; allContentFieldNames.sort((a, b) => { const diff = (parentSortMap.get(a) ?? Infinity) - (parentSortMap.get(b) ?? Infinity); if (diff !== 0) return diff; return a.localeCompare(b); }); let nextSort = maxSystemSort + 1; const sortAssignments = /* @__PURE__ */ new Map(); for (const fieldName of allContentFieldNames) sortAssignments.set(fieldName, nextSort++); for (const existing of existingContentFields) { const newSort = sortAssignments.get(existing.field); if (newSort !== void 0 && newSort !== existing.sort) await trx("directus_fields").where({ collection: translationsCollection, field: existing.field }).update({ sort: newSort }); } for (const clonedField of clonedFields$1) { const sort = sortAssignments.get(clonedField.field); if (sort !== void 0) clonedField.meta = { ...clonedField.meta, sort }; } clonedFields$1.sort((a, b) => (sortAssignments.get(a.field) ?? Infinity) - (sortAssignments.get(b.field) ?? Infinity)); const fieldsService$1 = new FieldsService(getServiceOptions(currentSchema)); for (const clonedField of clonedFields$1) await fieldsService$1.createField(translationsCollection, clonedField, void 0, mutationOptions); } return { created: false, translationsCollection, fields: fieldNames }; } if (Object.values(sourceCollection.fields).some((field) => Array.isArray(field.special) && field.special.includes("translations"))) throw new InvalidPayloadError({ reason: `Field with special "translations" already exists on "${collection}"` }); if (sourceCollection.fields["translations"]) throw new InvalidPayloadError({ reason: `Field "translations" already exists on "${collection}"` }); const parentFieldNames = Object.keys(sourceCollection.fields); const parentPrimaryKeyField = sourceCollection.primary; if (!parentPrimaryKeyField) throw new InvalidPayloadError({ reason: `Collection "${collection}" does not have a primary key` }); const parentPrimaryKeyType = sourceCollection.fields[parentPrimaryKeyField]?.type ?? "integer"; for (const fieldName of fieldNames) if (!parentFieldNames.includes(fieldName)) throw new InvalidPayloadError({ reason: `Field "${fieldName}" does not exist on "${collection}"` }); let languagePrimaryKeyField; let languagePrimaryKeyType; if (currentSchema.collections[languagesCollection]) { const existingLanguagesCollection = currentSchema.collections[languagesCollection]; languagePrimaryKeyField = existingLanguagesCollection.primary; languagePrimaryKeyType = existingLanguagesCollection.fields[languagePrimaryKeyField]?.type ?? "string"; } else if (createLanguagesCollection) { await new CollectionsService(getServiceOptions(currentSchema)).createOne({ collection: languagesCollection, meta: {}, schema: { name: languagesCollection }, fields: [ { field: "code", type: "string", schema: { is_primary_key: true }, meta: {} }, { field: "name", type: "string", schema: {}, meta: {} }, { field: "direction", type: "string", schema: { default_value: "ltr" }, meta: { interface: "select-dropdown", options: { choices: [{ text: "$t:left_to_right", value: "ltr" }, { text: "$t:right_to_left", value: "rtl" }] }, display: "labels", display_options: { choices: [{ text: "$t:left_to_right", value: "ltr" }, { text: "$t:right_to_left", value: "rtl" }], format: false } } } ] }, mutationOptions); currentSchema = await getSchema({ database: trx, bypassCache: true }); const createdLanguagesCollection = currentSchema.collections[languagesCollection]; if (!createdLanguagesCollection) throw new InvalidPayloadError({ reason: `Languages collection "${languagesCollection}" was not found in refreshed schema` }); languagePrimaryKeyField = createdLanguagesCollection.primary; languagePrimaryKeyType = createdLanguagesCollection.fields[languagePrimaryKeyField]?.type ?? "string"; if (seedLanguages) await new ItemsService(languagesCollection, getServiceOptions(currentSchema)).createMany([ { code: "en-US", name: "English", direction: "ltr" }, { code: "ar-SA", name: "Arabic", direction: "rtl" }, { code: "de-DE", name: "German", direction: "ltr" }, { code: "fr-FR", name: "French", direction: "ltr" }, { code: "ru-RU", name: "Russian", direction: "ltr" }, { code: "es-ES", name: "Spanish", direction: "ltr" }, { code: "it-IT", name: "Italian", direction: "ltr" }, { code: "pt-BR", name: "Portuguese", direction: "ltr" } ], mutationOptions); } else throw new InvalidPayloadError({ reason: `Languages collection "${languagesCollection}" does not exist` }); const sourceFields = await readSourceFields(); validateFieldsEligibility(sourceFields); const resolvedParentForeignKeyField = parentFkField ?? `${collection}_${parentPrimaryKeyField}`; const resolvedLanguageForeignKeyField = languageFkField ?? `${languagesCollection}_${languagePrimaryKeyField}`; const duplicateTargetField = findDuplicate([ "id", resolvedParentForeignKeyField, resolvedLanguageForeignKeyField, ...fieldNames ]); if (duplicateTargetField) throw new InvalidPayloadError({ reason: `Field "${duplicateTargetField}" would be created multiple times on "${translationsCollection}"` }); const clonedFields = cloneFields({ fields: fieldNames, sourceFields }); await new CollectionsService(getServiceOptions(currentSchema)).createOne({ collection: translationsCollection, meta: { hidden: true, icon: "import_export" }, schema: { name: translationsCollection }, fields: [ { field: "id", type: "integer", schema: { has_auto_increment: true }, meta: { hidden: true } }, { field: resolvedParentForeignKeyField, type: parentPrimaryKeyType, schema: {}, meta: { hidden: true } }, { field: resolvedLanguageForeignKeyField, type: languagePrimaryKeyType, schema: {}, meta: { hidden: true } }, ...clonedFields ] }, mutationOptions); currentSchema = await getSchema({ database: trx, bypassCache: true }); const relationsService = new RelationsService(getServiceOptions(currentSchema)); const onDeleteSchema = { on_delete: "SET NULL" }; const parentRelationPayload = { collection: translationsCollection, field: resolvedParentForeignKeyField, related_collection: collection, meta: { one_field: "translations", sort_field: null, one_deselect_action: "nullify", junction_field: resolvedLanguageForeignKeyField }, schema: onDeleteSchema }; await relationsService.createOne(parentRelationPayload, mutationOptions); const languageRelationPayload = { collection: translationsCollection, field: resolvedLanguageForeignKeyField, related_collection: languagesCollection, meta: { one_field: null, sort_field: null, one_deselect_action: "nullify", junction_field: resolvedParentForeignKeyField }, schema: onDeleteSchema }; await relationsService.createOne(languageRelationPayload, mutationOptions); currentSchema = await getSchema({ database: trx, bypassCache: true }); const fieldsService = new FieldsService(getServiceOptions(currentSchema)); const resolvedLanguagesCollection = currentSchema.collections[languagesCollection]; if (!resolvedLanguagesCollection) throw new InvalidPayloadError({ reason: `Languages collection "${languagesCollection}" was not found in refreshed schema` }); await fieldsService.createField(collection, buildTranslationsAliasField(resolvedLanguagesCollection.fields), void 0, mutationOptions); return { created: true, translationsCollection, languagesCollection, fields: fieldNames }; }); if (nestedActionEvents.length > 0) try { const updatedSchema = await getSchema({ database: options.knex, bypassCache: true }); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } catch (error) { logger.warn(error, "Failed to emit nested translation actions after generate-translations commit"); } return result; } //#endregion export { generateTranslations };