'use strict'; const _ = require('lodash'); const { prop, pick, reduce, map, keys, toPath, isNil } = require('lodash/fp'); const utils = require('@strapi/utils'); const { getService } = require('../utils'); const { contentTypes, parseMultipartData, sanitize } = utils; const { ApplicationError, NotFoundError } = utils.errors; const { getContentTypeRoutePrefix, isSingleType, getWritableAttributes } = contentTypes; /** * Returns all locales for an entry * @param {object} entry * @returns {string[]} */ const getAllLocales = (entry) => { return [entry.locale, ...map(prop('locale'), entry.localizations)]; }; /** * Returns all localizations ids for an entry * @param {object} entry * @returns {any[]} */ const getAllLocalizationsIds = (entry) => { return [entry.id, ...map(prop('id'), entry.localizations)]; }; /** * Returns a sanitizer object with a data & a file sanitizer for a content type * @param {object} contentType * @returns {{ * sanitizeInput(data: object): object, * sanitizeInputFiles(files: object): object * }} */ const createSanitizer = (contentType) => { /** * Returns the writable attributes of a content type in the localization routes * @returns {string[]} */ const getAllowedAttributes = () => { return getWritableAttributes(contentType).filter( (attributeName) => !['locale', 'localizations'].includes(attributeName) ); }; /** * Sanitizes uploaded files to keep only writable ones * @param {object} files - input files to sanitize * @returns {object} */ const sanitizeInputFiles = (files) => { const allowedFields = getAllowedAttributes(); return reduce( (acc, keyPath) => { const [rootKey] = toPath(keyPath); if (allowedFields.includes(rootKey)) { acc[keyPath] = files[keyPath]; } return acc; }, {}, keys(files) ); }; /** * Sanitizes input data to keep only writable attributes * @param {object} data - input data to sanitize * @returns {object} */ const sanitizeInput = (data) => { return pick(getAllowedAttributes(), data); }; return { sanitizeInput, sanitizeInputFiles }; }; /** * Returns a handler to handle localizations creation in the core api * @param {object} contentType * @returns {(object) => void} */ const createLocalizationHandler = (contentType) => { const handler = createCreateLocalizationHandler(contentType); return (ctx = {}) => { const { id } = ctx.params; const { data, files } = parseMultipartData(ctx); return handler({ id, data, files }); }; }; const createCreateLocalizationHandler = (contentType) => async (args = {}) => { const { copyNonLocalizedAttributes } = getService('content-types'); const { sanitizeInput, sanitizeInputFiles } = createSanitizer(contentType); const entry = isSingleType(contentType) ? await strapi.query(contentType.uid).findOne({ populate: ['localizations'] }) : await strapi .query(contentType.uid) .findOne({ where: { id: args.id }, populate: ['localizations'] }); if (!entry) { throw new NotFoundError(); } const { data, files } = args; const { findByCode } = getService('locales'); if (isNil(data.locale)) { throw new ApplicationError('locale is missing'); } const matchingLocale = await findByCode(data.locale); if (!matchingLocale) { throw new ApplicationError('locale is invalid'); } const usedLocales = getAllLocales(entry); if (usedLocales.includes(data.locale)) { throw new ApplicationError('locale is already used'); } const sanitizedData = { ...copyNonLocalizedAttributes(contentType, entry), ...sanitizeInput(data), locale: data.locale, localizations: getAllLocalizationsIds(entry), }; const sanitizedFiles = sanitizeInputFiles(files); const newEntry = await strapi.entityService.create(contentType.uid, { data: sanitizedData, files: sanitizedFiles, populate: ['localizations'], }); return sanitize.contentAPI.output(newEntry, strapi.getModel(contentType.uid)); }; /** * Returns a route config to handle localizations creation in the core api * @param {object} contentType * @returns {{ method: string, path: string, handler: string, config: { policies: string[] }}} */ const createLocalizationRoute = (contentType) => { const { modelName } = contentType; const routePrefix = getContentTypeRoutePrefix(contentType); const routePath = isSingleType(contentType) ? `/${routePrefix}/localizations` : `/${routePrefix}/:id/localizations`; return { method: 'POST', path: routePath, handler: `${modelName}.createLocalization`, config: { policies: [], }, }; }; /** * Adds a route & an action to the core api controller of a content type to allow creating new localizations * @param {object} contentType */ const addCreateLocalizationAction = (contentType) => { const { modelName, apiName } = contentType; const localizationRoute = createLocalizationRoute(contentType); strapi.api[apiName].routes[modelName].routes.push(localizationRoute); strapi.container.get('controllers').extend(`api::${apiName}.${modelName}`, (controller) => { return Object.assign(controller, { createLocalization: createLocalizationHandler(contentType), }); }); }; const mergeCustomizer = (dest, src) => { if (typeof dest === 'string') { return `${dest}\n${src}`; } }; /** * Add a graphql schema to the plugin's global graphl schema to be processed * @param {object} schema */ const addGraphqlSchema = (schema) => { _.mergeWith(strapi.config.get('plugin.i18n.schema.graphql'), schema, mergeCustomizer); }; /** * Add localization mutation & filters to use with the graphql plugin * @param {object} contentType */ const addGraphqlLocalizationAction = (contentType) => { const { globalId, modelName } = contentType; if (!strapi.plugins.graphql) { return; } const { toSingular, toPlural } = strapi.plugin('graphql').service('naming'); // We use a string instead of an enum as the locales can be changed in the admin // NOTE: We could use a custom scalar so the validation becomes dynamic const localeArgs = { args: { locale: 'String', }, }; // add locale arguments in the existing queries if (isSingleType(contentType)) { const queryName = toSingular(modelName); const mutationSuffix = _.upperFirst(queryName); addGraphqlSchema({ resolver: { Query: { [queryName]: localeArgs, }, Mutation: { [`update${mutationSuffix}`]: localeArgs, [`delete${mutationSuffix}`]: localeArgs, }, }, }); } else { const queryName = toPlural(modelName); addGraphqlSchema({ resolver: { Query: { [queryName]: localeArgs, [`${queryName}Connection`]: localeArgs, }, }, }); } // add new mutation to create a localization const typeName = globalId; const capitalizedName = _.upperFirst(toSingular(modelName)); const mutationName = `create${capitalizedName}Localization`; const mutationDef = `${mutationName}(input: update${capitalizedName}Input!): ${typeName}!`; const actionName = `${contentType.uid}.createLocalization`; addGraphqlSchema({ mutation: mutationDef, resolver: { Mutation: { [mutationName]: { resolver: actionName, }, }, }, }); }; module.exports = () => ({ addCreateLocalizationAction, addGraphqlLocalizationAction, createSanitizer, createCreateLocalizationHandler, });