Skip to content

Commit

Permalink
Merge pull request #1025 from ckeditor/ci/3833
Browse files Browse the repository at this point in the history
Feature (translations): Introduced `moveTranslations()` function to move requested translations between packages. It removes contexts and translated messages from language files ("*.po" files) from the source package and adds (or overwrites) them in the destination package.
  • Loading branch information
psmyrek authored Oct 21, 2024
2 parents 2afa211 + 5eb675f commit 2526d91
Show file tree
Hide file tree
Showing 16 changed files with 1,001 additions and 105 deletions.
1 change: 1 addition & 0 deletions packages/ckeditor5-dev-translations/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { default as findMessages } from './findmessages.js';
export { default as MultipleLanguageTranslationService } from './multiplelanguagetranslationservice.js';
export { default as CKEditorTranslationsPlugin } from './ckeditortranslationsplugin.js';
export { default as synchronizeTranslations } from './synchronizetranslations.js';
export { default as moveTranslations } from './movetranslations.js';
121 changes: 121 additions & 0 deletions packages/ckeditor5-dev-translations/lib/movetranslations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import fs from 'fs-extra';
import { logger } from '@ckeditor/ckeditor5-dev-utils';
import getPackageContext from './utils/getpackagecontext.js';
import moveTranslationsBetweenPackages from './utils/movetranslationsbetweenpackages.js';

/**
* Moves the requested translations (context and messages) between packages by performing the following steps:
* * Detect if translations to move are not duplicated.
* * Detect if both source and destination packages exist.
* * Detect if translation context to move exists in the source package. Message may not exist in the target package,
* but if it does, it will be overwritten.
* * If there are no validation errors, move the requested translations between packages: the context and the translation
* messages for each language found in the source package.
*
* @param {object} options
* @param {Array.<TranslationMoveEntry>} options.config Configuration that defines the messages to move.
*/
export default function moveTranslations( options ) {
const { config } = options;
const log = logger();

log.info( '📍 Loading translations contexts...' );
const packageContexts = config.flatMap( entry => [
getPackageContext( { packagePath: entry.source } ),
getPackageContext( { packagePath: entry.destination } )
] );

const errors = [];

log.info( '📍 Checking provided configuration...' );
errors.push(
...assertTranslationMoveEntriesUnique( { config } ),
...assertPackagesExist( { config } ),
...assertContextsExist( { packageContexts, config } )
);

if ( errors.length ) {
log.error( '🔥 The following errors have been found:' );

for ( const error of errors ) {
log.error( ` - ${ error }` );
}

process.exit( 1 );
}

log.info( '📍 Moving translations between packages...' );
moveTranslationsBetweenPackages( { packageContexts, config } );

log.info( '✨ Done.' );
}

/**
* @param {object} options
* @param {Array.<TranslationMoveEntry>} options.config Configuration that defines the messages to move.
* @returns {Array.<string>}
*/
function assertTranslationMoveEntriesUnique( { config } ) {
const moveEntriesGroupedByMessageId = config.reduce( ( result, entry ) => {
result[ entry.messageId ] = result[ entry.messageId ] || 0;
result[ entry.messageId ]++;

return result;
}, {} );

return Object.keys( moveEntriesGroupedByMessageId )
.filter( messageId => moveEntriesGroupedByMessageId[ messageId ] > 1 )
.map( messageId => `Duplicated entry: the "${ messageId }" message is configured to be moved multiple times.` );
}

/**
* @param {object} options
* @param {Array.<TranslationMoveEntry>} options.config Configuration that defines the messages to move.
* @returns {Array.<string>}
*/
function assertPackagesExist( { config } ) {
return config
.flatMap( entry => {
const missingPackages = [];

if ( !fs.existsSync( entry.source ) ) {
missingPackages.push( entry.source );
}

if ( !fs.existsSync( entry.destination ) ) {
missingPackages.push( entry.destination );
}

return missingPackages;
} )
.map( packagePath => `Missing package: the "${ packagePath }" package does not exist.` );
}

/**
* @param {object} options
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @param {Array.<TranslationMoveEntry>} options.config Configuration that defines the messages to move.
* @returns {Array.<string>}
*/
function assertContextsExist( { packageContexts, config } ) {
return config
.filter( entry => {
const packageContext = packageContexts.find( context => context.packagePath === entry.source );

return !packageContext.contextContent[ entry.messageId ];
} )
.map( entry => `Missing context: the "${ entry.messageId }" message does not exist in "${ entry.source }" package.` );
}

/**
* @typedef {object} TranslationMoveEntry
*
* @property {string} source Relative path to the source package from which the `messageId` should be moved.
* @property {string} destination Relative path to the destination package to which the `messageId` should be moved.
* @property {string} messageId The message identifier to move.
*/
33 changes: 7 additions & 26 deletions packages/ckeditor5-dev-translations/lib/synchronizetranslations.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { logger } from '@ckeditor/ckeditor5-dev-utils';
import getPackageContexts from './utils/getpackagecontexts.js';
import { CONTEXT_FILE_PATH } from './utils/constants.js';
import getSourceMessages from './utils/getsourcemessages.js';
import updatePackageTranslations from './utils/updatepackagetranslations.js';
import synchronizeTranslationsBasedOnContext from './utils/synchronizetranslationsbasedoncontext.js';

/**
* Synchronizes translations in provided packages by performing the following steps:
Expand Down Expand Up @@ -72,15 +72,15 @@ export default function synchronizeTranslations( options ) {
}

log.info( '📍 Synchronizing translations files...' );
updatePackageTranslations( { packageContexts, sourceMessages, skipLicenseHeader } );
synchronizeTranslationsBasedOnContext( { packageContexts, sourceMessages, skipLicenseHeader } );

log.info( '✨ Done.' );
}

/**
* @param {object} options
* @param {Array.<Context>} options.packageContexts An array of language contexts.
* @param {Array.<Message>} options.sourceMessages An array of i18n source messages.
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @param {Array.<TranslatableEntry>} options.sourceMessages An array of i18n source messages.
* @param {string} options.corePackagePath A relative to `process.cwd()` path to the `@ckeditor/ckeditor5-core` package.
* @returns {Array.<string>}
*/
Expand All @@ -105,8 +105,8 @@ function assertNoMissingContext( { packageContexts, sourceMessages, corePackageP

/**
* @param {object} options
* @param {Array.<Context>} options.packageContexts An array of language contexts.
* @param {Array.<Message>} options.sourceMessages An array of i18n source messages.
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @param {Array.<TranslatableEntry>} options.sourceMessages An array of i18n source messages.
* @param {string} options.corePackagePath A relative to `process.cwd()` path to the `@ckeditor/ckeditor5-core` package.
* @param {boolean} options.ignoreUnusedCorePackageContexts Whether to skip unused context errors related to the `@ckeditor/ckeditor5-core`
* package.
Expand Down Expand Up @@ -152,7 +152,7 @@ function assertAllContextUsed( { packageContexts, sourceMessages, corePackagePat

/**
* @param {object} options
* @param {Array.<Context>} options.packageContexts An array of language contexts.
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @returns {Array.<string>}
*/
function assertNoRepeatedContext( { packageContexts } ) {
Expand All @@ -176,22 +176,3 @@ function assertNoRepeatedContext( { packageContexts } ) {
return `Duplicated context "${ messageId }" in "${ contextFilePaths.join( '", "' ) }".`;
} );
}

/**
* @typedef {object} Message
*
* @property {string} id
* @property {string} string
* @property {string} filePath
* @property {string} packagePath
* @property {string} context
* @property {string} [plural]
*/

/**
* @typedef {object} Context
*
* @property {string} contextFilePath
* @property {object} contextContent
* @property {string} packagePath
*/
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const SUPPORTED_LOCALES = [
const LOCALES_FILENAME_MAP = {
'ne_NP': 'ne',
'si_LK': 'si',
'sr@latin': 'sr-latn',
'zh_TW': 'zh'
};

Expand Down
32 changes: 32 additions & 0 deletions packages/ckeditor5-dev-translations/lib/utils/getpackagecontext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import upath from 'upath';
import fs from 'fs-extra';
import { CONTEXT_FILE_PATH } from './constants.js';

/**
* @param {object} options
* @param {string} options.packagePath Path to the package containing the context.
* @returns {TranslationsContext}
*/
export default function getPackageContext( { packagePath } ) {
const contextFilePath = upath.join( packagePath, CONTEXT_FILE_PATH );
const contextContent = fs.readJsonSync( contextFilePath, { throws: false } ) || {};

return {
contextContent,
contextFilePath,
packagePath
};
}

/**
* @typedef {object} TranslationsContext
*
* @property {string} contextFilePath
* @property {object} contextContent
* @property {string} packagePath
*/
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@
* For licensing, see LICENSE.md.
*/

import upath from 'upath';
import fs from 'fs-extra';
import { CONTEXT_FILE_PATH } from './constants.js';
import getPackageContext from './getpackagecontext.js';

/**
* @param {object} options
* @param {Array.<string>} options.packagePaths An array of paths to packages, which will be used to find message contexts.
* @param {string} options.corePackagePath A relative to `process.cwd()` path to the `@ckeditor/ckeditor5-core` package.
* @returns {Array.<Context>}
* @returns {Array.<TranslationsContext>}
*/
export default function getPackageContexts( { packagePaths, corePackagePath } ) {
// Add path to the core package if not included in the package paths.
Expand All @@ -20,14 +18,5 @@ export default function getPackageContexts( { packagePaths, corePackagePath } )
packagePaths.push( corePackagePath );
}

return packagePaths.map( packagePath => {
const contextFilePath = upath.join( packagePath, CONTEXT_FILE_PATH );
const contextContent = fs.existsSync( contextFilePath ) ? fs.readJsonSync( contextFilePath ) : {};

return {
contextContent,
contextFilePath,
packagePath
};
} );
return packagePaths.map( packagePath => getPackageContext( { packagePath } ) );
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import findMessages from '../findmessages.js';
* @param {Array.<string>} options.packagePaths An array of paths to packages that contain source files with messages to translate.
* @param {Array.<string>} options.sourceFiles An array of source files that contain messages to translate.
* @param {Function} options.onErrorCallback Called when there is an error with parsing the source files.
* @returns {Array.<Message>}
* @returns {Array.<TranslatableEntry>}
*/
export default function getSourceMessages( { packagePaths, sourceFiles, onErrorCallback } ) {
return sourceFiles
Expand All @@ -30,3 +30,14 @@ export default function getSourceMessages( { packagePaths, sourceFiles, onErrorC
return sourceMessages;
} );
}

/**
* @typedef {object} TranslatableEntry
*
* @property {string} id
* @property {string} string
* @property {string} filePath
* @property {string} packagePath
* @property {string} context
* @property {string} [plural]
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import upath from 'upath';
import fs from 'fs-extra';
import PO from 'pofile';
import { glob } from 'glob';
import { TRANSLATION_FILES_PATH } from './constants.js';
import cleanTranslationFileContent from './cleantranslationfilecontent.js';

/**
* @param {object} options
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @param {Array.<TranslationMoveEntry>} options.config Configuration that defines the messages to move.
*/
export default function moveTranslationsBetweenPackages( { packageContexts, config } ) {
// For each message to move:
for ( const { source, destination, messageId } of config ) {
// (1) Skip the message if its source and destination package is the same.
if ( source === destination ) {
continue;
}

// (2) Move translation context from source package to destination package.
const sourcePackageContext = packageContexts.find( context => context.packagePath === source );
const destinationPackageContext = packageContexts.find( context => context.packagePath === destination );

destinationPackageContext.contextContent[ messageId ] = sourcePackageContext.contextContent[ messageId ];
delete sourcePackageContext.contextContent[ messageId ];

// (3) Prepare the list of paths to translation files ("*.po" files) in source and destination packages.
// The source package defines the list of files for both packages.
const translationFilesPattern = upath.join( source, TRANSLATION_FILES_PATH, '*.po' );
const translationFilePaths = glob.sync( translationFilesPattern )
.map( filePath => upath.basename( filePath ) )
.map( fileName => ( {
sourceTranslationFilePath: upath.join( source, TRANSLATION_FILES_PATH, fileName ),
destinationTranslationFilePath: upath.join( destination, TRANSLATION_FILES_PATH, fileName )
} ) );

// Then, for each translation file:
for ( const { sourceTranslationFilePath, destinationTranslationFilePath } of translationFilePaths ) {
// (3.1) Read the source translation file.
const sourceTranslationFile = fs.readFileSync( sourceTranslationFilePath, 'utf-8' );
const sourceTranslations = PO.parse( sourceTranslationFile );

// (3.2) Read the destination translation file.
// If the destination file does not exist, use the source file as a base and remove all translations.
const destinationTranslationFile = fs.existsSync( destinationTranslationFilePath ) ?
fs.readFileSync( destinationTranslationFilePath, 'utf-8' ) :
null;
const destinationTranslations = PO.parse( destinationTranslationFile || sourceTranslationFile );

if ( !destinationTranslationFile ) {
destinationTranslations.items = [];
}

// (3.3) Move the translation from source file to destination file.
const sourceMessage = sourceTranslations.items.find( item => item.msgid === messageId );
sourceTranslations.items = sourceTranslations.items.filter( item => item.msgid !== messageId );

destinationTranslations.items = destinationTranslations.items.filter( item => item.msgid !== messageId );
destinationTranslations.items.push( sourceMessage );

fs.outputFileSync( sourceTranslationFilePath, cleanTranslationFileContent( sourceTranslations ).toString(), 'utf-8' );
fs.outputFileSync( destinationTranslationFilePath, cleanTranslationFileContent( destinationTranslations ).toString(), 'utf-8' );
}

fs.outputJsonSync( sourcePackageContext.contextFilePath, sourcePackageContext.contextContent, { spaces: '\t' } );
fs.outputJsonSync( destinationPackageContext.contextFilePath, destinationPackageContext.contextContent, { spaces: '\t' } );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import cleanTranslationFileContent from './cleantranslationfilecontent.js';

/**
* @param {object} options
* @param {Array.<Context>} options.packageContexts An array of language contexts.
* @param {Array.<Message>} options.sourceMessages An array of i18n source messages.
* @param {Array.<TranslationsContext>} options.packageContexts An array of language contexts.
* @param {Array.<TranslatableEntry>} options.sourceMessages An array of i18n source messages.
* @param {boolean} options.skipLicenseHeader Whether to skip adding the license header to newly created translation files.
*/
export default function updatePackageTranslations( { packageContexts, sourceMessages, skipLicenseHeader } ) {
export default function synchronizeTranslationsBasedOnContext( { packageContexts, sourceMessages, skipLicenseHeader } ) {
// For each package:
for ( const { packagePath, contextContent } of packageContexts ) {
// (1) Skip packages that do not contain language context.
Expand Down Expand Up @@ -55,7 +55,7 @@ export default function updatePackageTranslations( { packageContexts, sourceMess
const item = new PO.Item( { nplurals: numberOfPluralForms } );

item.msgctxt = contextContent[ message.id ];
item.msgid = message.string;
item.msgid = message.id;
item.msgstr.push( '' );

if ( message.plural ) {
Expand Down
Loading

0 comments on commit 2526d91

Please sign in to comment.