diff --git a/playground/config/plugins.js b/playground/config/plugins.js index b2d47440..763fc8e0 100644 --- a/playground/config/plugins.js +++ b/playground/config/plugins.js @@ -1,7 +1,7 @@ module.exports = ({ env }) => ({ // ... translate: { - resolve: "../plugin", + resolve: '../plugin', enabled: true, config: { provider: 'deepl', diff --git a/plugin/admin/src/Hooks/useUpdateCollection.js b/plugin/admin/src/Hooks/useUpdateCollection.js new file mode 100644 index 00000000..5d6be4fa --- /dev/null +++ b/plugin/admin/src/Hooks/useUpdateCollection.js @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react' +import { request } from '@strapi/helper-plugin' +import pluginId from '../pluginId' +import getTrad from '../utils/getTrad' +import useAlert from './useAlert' + +export function useUpdateCollection() { + const [updates, setUpdates] = useState([]) + const [refetchIndex, setRefetchIndex] = useState(true) + + const { handleNotification } = useAlert() + + const refetch = () => setRefetchIndex((prevRefetchIndex) => !prevRefetchIndex) + + const fetchUpdates = async () => { + const { data, error } = await request( + `/${pluginId}/batch-update/updates/`, + { + method: 'GET', + } + ) + + if (error) { + handleNotification({ + type: 'warning', + id: error.message, + defaultMessage: 'Failed to fetch Updates', + link: error.link, + }) + } else if (Array.isArray(data)) { + setUpdates(data) + } + } + + const dismissUpdates = async (ids) => { + for (const id of ids) { + const { error } = await request( + `/${pluginId}/batch-update/dismiss/${id}`, + { + method: 'DELETE', + } + ) + + if (error) { + handleNotification({ + type: 'warning', + id: error.message, + defaultMessage: 'Failed to dismiss Update', + link: error.link, + }) + } + } + + refetch() + } + + const startUpdate = async (ids, sourceLocale) => { + const { error } = await request(`/${pluginId}/batch-update`, { + method: 'POST', + body: { + updatedEntryIDs: ids, + sourceLocale, + }, + }) + + if (error) { + handleNotification({ + type: 'warning', + id: error.message, + defaultMessage: 'Failed to translate collection', + link: error.link, + }) + } else { + refetch() + handleNotification({ + type: 'success', + id: getTrad('batch-translate.start-success'), + defaultMessage: 'Request to translate content-type was successful', + blockTransition: false, + }) + } + } + + useEffect(() => { + fetchUpdates() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refetchIndex]) + + return { + updates, + dismissUpdates, + startUpdate, + refetch, + handleNotification, + } +} + +export default useUpdateCollection diff --git a/plugin/admin/src/components/BatchUpdateTable/Table.js b/plugin/admin/src/components/BatchUpdateTable/Table.js new file mode 100644 index 00000000..7254723f --- /dev/null +++ b/plugin/admin/src/components/BatchUpdateTable/Table.js @@ -0,0 +1,230 @@ +import React, { memo, useState, useEffect } from 'react' +import { Table, Tbody, Thead, Th, Tr, Td } from '@strapi/design-system/Table' +import { ExclamationMarkCircle } from '@strapi/icons' +import { + Box, + Flex, + Stack, + Button, + Checkbox, + Select, + Option, + Dialog, + DialogBody, + DialogFooter, + Typography, +} from '@strapi/design-system' +import { useIntl } from 'react-intl' +import useCollection from '../../Hooks/useCollection' +import useUpdateCollection from '../../Hooks/useUpdateCollection' +import { getTrad } from '../../utils' + +const CollectionTable = () => { + const { formatMessage } = useIntl() + + const [selectedIDs, setSelectedIDs] = useState([]) + const [dialogOpen, setDialogOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [sourceLocale, setSourceLocale] = useState(null) + + const { locales } = useCollection() + + useEffect(() => { + if (Array.isArray(locales) && locales.length > 0) + setSourceLocale(locales[0].code) + }, [locales]) + + const { updates, startUpdate, dismissUpdates, refetch } = + useUpdateCollection() + + useEffect(() => { + const localeUses = updates + .filter(({ id }) => selectedIDs.includes(id)) + .reduce((acc, current) => { + for (const locale of current.attributes.localesWithUpdates) { + acc[locale] = (acc[locale] ?? 0) + 1 + } + + return acc + }, {}) + + const { code: mostUpdatedLocale } = Object.entries(localeUses).reduce( + (acc, [code, uses]) => (acc.uses < uses ? { code, uses } : acc), + { code: '', uses: 0 } + ) + + setSourceLocale(mostUpdatedLocale) + }, [selectedIDs, updates]) + + const onAction = () => { + setIsLoading(true) + startUpdate(selectedIDs, sourceLocale).then(() => { + setIsLoading(false) + setDialogOpen(false) + refetch() + }) + } + + const dismissSelected = () => { + dismissUpdates(selectedIDs).then(() => { + setSelectedIDs([]) + refetch() + }) + } + + return ( + + + + + + + + + + + + {locales.map((locale) => ( + + ))} + + + + {updates.map(({ id, attributes }, index) => ( + + + + + {locales.map(({ code }) => ( + + ))} + + ))} + +
+ 0} + disabled={updates.length === 0} + indeterminate={ + selectedIDs.length > 0 && selectedIDs.length < updates.length + } + onClick={() => { + if (selectedIDs.length === updates.length) { + setSelectedIDs([]) + } else { + setSelectedIDs(updates.map(({ id }) => id)) + } + }} + /> + + Type + + IDs + + {locale.name} +
+ { + if (selectedIDs.includes(id)) { + setSelectedIDs((selectedIDs) => + selectedIDs.filter((param) => param !== id) + ) + } else { + setSelectedIDs((selectedIDs) => [id, ...selectedIDs]) + } + }} + /> + + {attributes.contentType} + + + {attributes.groupID.split('-').join(',')} + + + + {attributes.localesWithUpdates.includes(code) + ? 'was updated' + : ''} + +
+ {dialogOpen && ( + setDialogOpen(false)} + title={formatMessage({ + id: getTrad(`batch-update.dialog.title`), + defaultMessage: 'Confirmation', + })} + isOpen={dialogOpen} + > + }> + + + + {formatMessage({ + id: getTrad(`batch-update.dialog.content`), + defaultMessage: 'Confirmation body', + })} + + + + + + + + + + setDialogOpen(false)} variant="tertiary"> + {formatMessage({ + id: 'popUpWarning.button.cancel', + defaultMessage: 'No, cancel', + })} + + } + endAction={ + + } + /> + + )} +
+ ) +} + +export default memo(CollectionTable) diff --git a/plugin/admin/src/components/BatchUpdateTable/index.js b/plugin/admin/src/components/BatchUpdateTable/index.js new file mode 100644 index 00000000..ae29386b --- /dev/null +++ b/plugin/admin/src/components/BatchUpdateTable/index.js @@ -0,0 +1 @@ +export { default as BatchUpdateTable } from './Table' diff --git a/plugin/admin/src/components/PluginPage/index.js b/plugin/admin/src/components/PluginPage/index.js index 26df3263..0d592fce 100644 --- a/plugin/admin/src/components/PluginPage/index.js +++ b/plugin/admin/src/components/PluginPage/index.js @@ -1,14 +1,16 @@ import React, { memo } from 'react' -import { Box } from '@strapi/design-system/Box' +import { Stack } from '@strapi/design-system/Stack' import { CollectionTable } from '../Collection' +import { BatchUpdateTable } from '../BatchUpdateTable' import UsageOverview from '../Usage' const PluginPage = () => { return ( - + + - + ) } diff --git a/plugin/admin/src/components/Usage/index.js b/plugin/admin/src/components/Usage/index.js index a793c020..dbd7deca 100644 --- a/plugin/admin/src/components/Usage/index.js +++ b/plugin/admin/src/components/Usage/index.js @@ -58,7 +58,7 @@ const UsageOverview = () => { ) return ( - + diff --git a/plugin/admin/src/translations/de.json b/plugin/admin/src/translations/de.json index b1efa51f..bc4cd5e8 100644 --- a/plugin/admin/src/translations/de.json +++ b/plugin/admin/src/translations/de.json @@ -48,5 +48,9 @@ "usage.failed-to-load": "Daten konnten nicht geladen werden", "usage.characters-used": "Zeichen verbraucht", "usage.estimatedUsage": "Diese Aktion wird Ihre API-Nutzung ungefähr um diesen Betrag erhöht: ", - "usage.estimatedUsageExceedsQuota": "Diese Aktion wird voraussichtlich Ihr API-Kontingent überschreiten." + "usage.estimatedUsageExceedsQuota": "Diese Aktion wird voraussichtlich Ihr API-Kontingent überschreiten.", + "batch-update.dialog.title": "Massenanpassung starten", + "batch-update.dialog.content": "Für alle ausgewählten Elemente werden alle Lokalisierungen mit Übersetzungen der ausgewählten Ausgangssprache ersetzt.", + "batch-update.sourceLocale": "Ausgangssprache", + "batch-update.dialog.submit-text": "starten" } diff --git a/plugin/admin/src/translations/en.json b/plugin/admin/src/translations/en.json index 2c67dce7..2a114b0d 100644 --- a/plugin/admin/src/translations/en.json +++ b/plugin/admin/src/translations/en.json @@ -48,5 +48,9 @@ "usage.failed-to-load": "failed to load usage data", "usage.characters-used": "characters used", "usage.estimatedUsage": "This action is expected to increase your API usage by: ", - "usage.estimatedUsageExceedsQuota": "This action is expected to exceed your API Quota" + "usage.estimatedUsageExceedsQuota": "This action is expected to exceed your API Quota", + "batch-update.dialog.title": "start batch updating", + "batch-update.dialog.content": "All localizations of selected elements will be replaced by translations from the selected source locale.", + "batch-update.sourceLocale": "source locale", + "batch-update.dialog.submit-text": "start" } diff --git a/plugin/admin/src/translations/fr.json b/plugin/admin/src/translations/fr.json index 06a885ef..af099468 100644 --- a/plugin/admin/src/translations/fr.json +++ b/plugin/admin/src/translations/fr.json @@ -48,5 +48,9 @@ "usage.failed-to-load": "échec de la récupération des données", "usage.characters-used": "caractères consommés", "usage.estimatedUsage": "Cette action devrait augmenter l'utilisation de votre API de: ", - "usage.estimatedUsageExceedsQuota": "Cette action devrait dépasser votre quota d'API." + "usage.estimatedUsageExceedsQuota": "Cette action devrait dépasser votre quota d'API.", + "batch-update.dialog.title": "mise à jour par lots", + "batch-update.dialog.content": "Toutes les localisations des éléments sélectionnés seront remplacées par des traductions de la langue source sélectionnée.", + "batch-update.sourceLocale": "source locale", + "batch-update.dialog.submit-text": "commencer" } diff --git a/plugin/server/bootstrap.js b/plugin/server/bootstrap.js index af770a64..8bf90b80 100644 --- a/plugin/server/bootstrap.js +++ b/plugin/server/bootstrap.js @@ -39,6 +39,35 @@ const createProvider = (translateConfig) => { module.exports = async ({ strapi }) => { const translateConfig = strapi.config.get('plugin.translate') strapi.plugin('translate').provider = createProvider(translateConfig) + + // Listen for updates to entries, mark them as updated + strapi.db.lifecycles.subscribe({ + afterUpdate(event) { + if ( + event?.result?.locale && + Array.isArray(event.result.localizations) && + event.result.localizations.length > 0 && + Object.keys(event.params.data).some( + (key) => !['localizations', 'updatedAt', 'updatedBy'].includes(key) + ) + ) { + const groupID = [ + event.result.id, + ...event.result.localizations.map(({ id }) => id), + ] + .sort() + .join('-') + getService('updated-entry').create({ + data: { + contentType: event.model.uid, + groupID, + localesWithUpdates: [event.result.locale], + }, + }) + } + }, + }) + await strapi.admin.services.permission.actionProvider.registerMany(actions) await getService('translate').batchTranslateManager.bootstrap() } diff --git a/plugin/server/content-types/index.js b/plugin/server/content-types/index.js index 0f8b7e9e..bfca038d 100644 --- a/plugin/server/content-types/index.js +++ b/plugin/server/content-types/index.js @@ -1,7 +1,9 @@ 'use strict' const batchTranslateJob = require('./batch-translate-job') +const updatedEntry = require('./updated-entry') module.exports = { 'batch-translate-job': batchTranslateJob, + 'updated-entry': updatedEntry, } diff --git a/plugin/server/content-types/updated-entry/index.js b/plugin/server/content-types/updated-entry/index.js new file mode 100644 index 00000000..abdcdd4e --- /dev/null +++ b/plugin/server/content-types/updated-entry/index.js @@ -0,0 +1,7 @@ +'use strict' + +const schema = require('./schema.json') + +module.exports = { + schema, +} diff --git a/plugin/server/content-types/updated-entry/schema.json b/plugin/server/content-types/updated-entry/schema.json new file mode 100644 index 00000000..69e4b32e --- /dev/null +++ b/plugin/server/content-types/updated-entry/schema.json @@ -0,0 +1,33 @@ +{ + "kind": "collectionType", + "collectionName": "translate_updated_entries", + "info": { + "singularName": "updated-entry", + "pluralName": "updated-entries", + "displayName": "Translate updated Entry" + }, + "options": { + "draftAndPublish": false, + "comment": "" + }, + "pluginOptions": { + "content-manager": { + "visible": true, + "comment": "// FIXME: debug only" + }, + "content-type-builder": { + "visible": false + } + }, + "attributes": { + "contentType": { + "type": "string" + }, + "groupID": { + "type": "string" + }, + "localesWithUpdates": { + "type": "json" + } + } +} diff --git a/plugin/server/controllers/index.js b/plugin/server/controllers/index.js index 4eac7d8e..2c299767 100644 --- a/plugin/server/controllers/index.js +++ b/plugin/server/controllers/index.js @@ -3,9 +3,11 @@ const batchTranslateJob = require('./batch-translate-job') const provider = require('./provider') const translate = require('./translate') +const updatedEntry = require('./updated-entry') module.exports = { 'batch-translate-job': batchTranslateJob, provider, translate, + 'updated-entry': updatedEntry, } diff --git a/plugin/server/controllers/translate.js b/plugin/server/controllers/translate.js index 748a12c1..6d10531c 100644 --- a/plugin/server/controllers/translate.js +++ b/plugin/server/controllers/translate.js @@ -223,6 +223,24 @@ module.exports = ({ strapi }) => ({ }, } }, + async batchUpdate(ctx) { + const { sourceLocale, updatedEntryIDs } = ctx.request.body + + if (!sourceLocale) { + return ctx.badRequest('source locale is required') + } + + if (!Array.isArray(updatedEntryIDs)) { + return ctx.badRequest('updatedEntryIDs must be an array') + } + + ctx.body = { + data: await getService('translate').batchUpdate({ + updatedEntryIDs, + sourceLocale, + }), + } + }, async batchTranslateContentTypes(ctx) { ctx.body = { data: await getService('translate').contentTypes(), diff --git a/plugin/server/controllers/updated-entry.js b/plugin/server/controllers/updated-entry.js new file mode 100644 index 00000000..1b5ffcab --- /dev/null +++ b/plugin/server/controllers/updated-entry.js @@ -0,0 +1,5 @@ +'use strict' + +const { createCoreController } = require('@strapi/strapi').factories + +module.exports = createCoreController('plugin::translate.updated-entry') diff --git a/plugin/server/routes/translate.js b/plugin/server/routes/translate.js index 71dc3371..ce3b9c64 100644 --- a/plugin/server/routes/translate.js +++ b/plugin/server/routes/translate.js @@ -99,6 +99,48 @@ module.exports = [ ], }, }, + { + method: 'POST', + path: '/batch-update', + handler: 'translate.batchUpdate', + config: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::translate.batch-translate'] }, + }, + ], + }, + }, + { + method: 'GET', + path: '/batch-update/updates', + handler: 'updated-entry.find', + config: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::translate.batch-translate'] }, + }, + ], + }, + }, + { + method: 'DELETE', + path: '/batch-update/dismiss/:id', + handler: 'updated-entry.delete', + config: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::translate.batch-translate'] }, + }, + ], + }, + }, { method: 'POST', path: '/usage/estimate', diff --git a/plugin/server/routes/updated-entry.js b/plugin/server/routes/updated-entry.js new file mode 100644 index 00000000..b5c728b1 --- /dev/null +++ b/plugin/server/routes/updated-entry.js @@ -0,0 +1,31 @@ +'use strict' + +/** + * router. + */ + +const { factories } = require('@strapi/strapi') + +module.exports = factories.createCoreRouter('plugin::translate.updated-entry', { + config: { + find: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::translate.translate'] }, + }, + ], + }, + delete: { + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'plugin::content-manager.hasPermissions', + config: { actions: ['plugin::translate.translate'] }, + }, + ], + }, + }, + only: ['find', 'delete'], +}) diff --git a/plugin/server/services/index.js b/plugin/server/services/index.js index 7f237b01..9040a459 100644 --- a/plugin/server/services/index.js +++ b/plugin/server/services/index.js @@ -6,6 +6,7 @@ const provider = require('./provider') const translate = require('./translate') const untranslated = require('./untranslated') const format = require('./format') +const updatedEntry = require('./updated-entry') module.exports = { 'batch-translate-job': batchTranslateJob, @@ -14,4 +15,5 @@ module.exports = { untranslated, chunks, format, + 'updated-entry': updatedEntry, } diff --git a/plugin/server/services/translate.js b/plugin/server/services/translate.js index 52aa90e4..5af9f87c 100644 --- a/plugin/server/services/translate.js +++ b/plugin/server/services/translate.js @@ -5,6 +5,11 @@ const set = require('lodash/set') const groupBy = require('lodash/groupBy') const { getService } = require('../utils/get-service') +const { getAllTranslatableFields } = require('../utils/translatable-fields') +const { filterAllDeletedFields } = require('../utils/delete-fields') +const { cleanData } = require('../utils/clean-data') +const { TRANSLATE_PRIORITY_BATCH_TRANSLATION } = require('../utils/constants') +const { updateUids } = require('../utils/update-uids') const { BatchTranslateManager } = require('./batch-translate') module.exports = ({ strapi }) => ({ @@ -69,6 +74,75 @@ module.exports = ({ strapi }) => ({ async batchTranslateCancelJob(id) { return this.batchTranslateManager.cancelJob(id) }, + async batchUpdate(params) { + const { updatedEntryIDs, sourceLocale } = params + for (const updateID of updatedEntryIDs) { + const update = await strapi + .service('plugin::translate.updated-entry') + .findOne(updateID) + + if (!update) continue + const mainID = Number(update.groupID.split('-')[0]) + + const entity = await strapi.db.query(update.contentType).findOne({ + where: { id: mainID }, + populate: { localizations: true }, + }) + + const normalizedEntities = [entity, ...entity.localizations] + + normalizedEntities.forEach((normalizedEntity) => { + delete normalizedEntity.localizations + }) + + const sourceEntity = normalizedEntities.find( + ({ locale }) => locale === sourceLocale + ) + + if (!sourceEntity) + throw new Error('No entity found with locale ' + sourceLocale) + + const targets = normalizedEntities + .map(({ locale, id }) => ({ id, locale })) + .filter(({ locale }) => locale !== sourceLocale) + + const contentTypeSchema = strapi.contentTypes[update.contentType] + const fieldsToTranslate = await getAllTranslatableFields( + sourceEntity, + contentTypeSchema + ) + + for (const { locale, id } of targets) { + const translated = await this.translate({ + sourceLocale, + targetLocale: locale, + priority: TRANSLATE_PRIORITY_BATCH_TRANSLATION, + fieldsToTranslate, + data: sourceEntity, + }) + + const uidsUpdated = await updateUids(translated, update.contentType) + + const withFieldsDeleted = filterAllDeletedFields( + uidsUpdated, + update.contentType + ) + + const fullyTranslatedData = cleanData( + withFieldsDeleted, + contentTypeSchema + ) + + delete fullyTranslatedData.locale + + strapi.db.query(update.contentType).update({ + where: { id }, + data: fullyTranslatedData, + }) + } + await strapi.service('plugin::translate.updated-entry').delete(updateID) + } + }, async contentTypes() { const localizedContentTypes = Object.keys(strapi.contentTypes).filter( (ct) => strapi.contentTypes[ct].pluginOptions?.i18n?.localized @@ -79,7 +153,7 @@ module.exports = ({ strapi }) => ({ const reports = await Promise.all( localizedContentTypes.map(async (contentType) => { // get jobs - const jobs = await strapi.db + const translateJobs = await strapi.db .query('plugin::translate.batch-translate-job') .findMany({ where: { contentType: { $eq: contentType } }, @@ -92,10 +166,12 @@ module.exports = ({ strapi }) => ({ const countPromise = strapi.db .query(contentType) .count({ where: { locale: code } }) + const complete = await getService('untranslated').isFullyTranslated( contentType, code ) + return { count: await countPromise, complete, @@ -108,7 +184,7 @@ module.exports = ({ strapi }) => ({ locales.forEach(({ code }, index) => { localeReports[code] = { ...info[index], - job: jobs.find((job) => job.targetLocale === code), + job: translateJobs.find((job) => job.targetLocale === code), } }) return { @@ -118,6 +194,7 @@ module.exports = ({ strapi }) => ({ } }) ) + return { contentTypes: reports, locales } }, }) diff --git a/plugin/server/services/updated-entry.js b/plugin/server/services/updated-entry.js new file mode 100644 index 00000000..7dc52ac5 --- /dev/null +++ b/plugin/server/services/updated-entry.js @@ -0,0 +1,32 @@ +'use strict' + +const { createCoreService } = require('@strapi/strapi').factories + +module.exports = createCoreService('plugin::translate.updated-entry', () => ({ + async create(params) { + try { + const { + results: [firstResult], + } = await super.find({ + fields: ['id'], + filters: { + contentType: params.data.contentType, + groupID: params.data.groupID, + }, + }) + super.update(firstResult.id, { + localesWithUpdates: Array.from( + new Set([ + ...(firstResult.localesWithUpdates ?? []), + ...(params.data.localesWithUpdates ?? []), + ]) + ), + }) + if (firstResult) return firstResult + } catch (e) { + console.error(e) + } + + return super.create(params) + }, +}))