diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 29e8f4633..c429c26ed 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -22,8 +22,7 @@ module.exports = { // ESLint does not understand `import ... with { ... }`. // See: https://github.com/eslint/eslint/discussions/15305. - 'packages/ckeditor5-dev-ci/lib/data/index.js', - 'packages/ckeditor5-dev-transifex/lib/data/index.js' + 'packages/ckeditor5-dev-ci/lib/data/index.js' ], rules: { 'no-console': 'off', @@ -43,7 +42,7 @@ module.exports = { './packages/typedoc-plugins/**/*' ], rules: { - 'ckeditor5-rules/require-file-extensions-in-imports': 'off', + 'ckeditor5-rules/require-file-extensions-in-imports': 'off' } } ] diff --git a/README.md b/README.md index 16dfab7aa..bd5557a91 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ This repository is a monorepo. It contains multiple npm packages. | [`@ckeditor/ckeditor5-dev-docs`](/packages/ckeditor5-dev-docs) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-docs.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-docs) | | [`@ckeditor/ckeditor5-dev-release-tools`](/packages/ckeditor5-dev-release-tools) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-release-tools.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-release-tools) | | [`@ckeditor/ckeditor5-dev-tests`](/packages/ckeditor5-dev-tests) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-tests.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-tests) | -| [`@ckeditor/ckeditor5-dev-transifex`](/packages/ckeditor5-dev-transifex) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-transifex.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-transifex) | | [`@ckeditor/ckeditor5-dev-utils`](/packages/ckeditor5-dev-utils) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-utils.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-utils) | | [`@ckeditor/ckeditor5-dev-translations`](/packages/ckeditor5-dev-translations) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-translations.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-translations) | | [`@ckeditor/ckeditor5-dev-web-crawler`](/packages/ckeditor5-dev-web-crawler) | [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-web-crawler.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-web-crawler) | diff --git a/package.json b/package.json index c855e5c18..424efcce6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@ckeditor/ckeditor5-dev-bump-year": "^44.2.1", "@inquirer/prompts": "^6.0.0", "@listr2/prompt-adapter-inquirer": "^2.0.16", + "@octokit/rest": "^21.0.0", "eslint": "^8.21.0", "eslint-config-ckeditor5": "^8.0.0", "fs-extra": "^11.0.0", diff --git a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/de.po b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/de.po index ebfc37c94..3116e3588 100644 --- a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/de.po +++ b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/de.po @@ -1,6 +1,5 @@ msgid "" msgstr "" -"Language-Team: German (https://app.transifex.com/ckeditor/teams/11143/de/)\n" "Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/nested/pl.po b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/nested/pl.po index 307024e28..f37e31c0d 100644 --- a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/nested/pl.po +++ b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/nested/pl.po @@ -1,6 +1,5 @@ msgid "" msgstr "" -"Language-Team: Polish (https://app.transifex.com/ckeditor/teams/11143/pl/)\n" "Language: pl\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/pl.po b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/pl.po index 15e2559d3..5514c1338 100644 --- a/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/pl.po +++ b/packages/ckeditor5-dev-build-tools/tests/plugins/translations/fixtures/pl.po @@ -1,6 +1,5 @@ msgid "" msgstr "" -"Language-Team: Polish (https://app.transifex.com/ckeditor/teams/11143/pl/)\n" "Language: pl\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/packages/ckeditor5-dev-release-tools/lib/index.js b/packages/ckeditor5-dev-release-tools/lib/index.js index 7ea9bb078..62e308f88 100644 --- a/packages/ckeditor5-dev-release-tools/lib/index.js +++ b/packages/ckeditor5-dev-release-tools/lib/index.js @@ -30,7 +30,6 @@ export { default as saveChangelog } from './utils/savechangelog.js'; export { default as executeInParallel } from './utils/executeinparallel.js'; export { default as validateRepositoryToRelease } from './utils/validaterepositorytorelease.js'; export { default as checkVersionAvailability } from './utils/checkversionavailability.js'; -export { default as verifyPackagesPublishedCorrectly } from './tasks/verifypackagespublishedcorrectly.js'; export { default as getNpmTagFromVersion } from './utils/getnpmtagfromversion.js'; export { default as isVersionPublishableForTag } from './utils/isversionpublishablefortag.js'; export { default as provideToken } from './utils/providetoken.js'; diff --git a/packages/ckeditor5-dev-release-tools/lib/tasks/verifypackagespublishedcorrectly.js b/packages/ckeditor5-dev-release-tools/lib/tasks/verifypackagespublishedcorrectly.js deleted file mode 100644 index 31f27d76f..000000000 --- a/packages/ckeditor5-dev-release-tools/lib/tasks/verifypackagespublishedcorrectly.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import upath from 'upath'; -import { glob } from 'glob'; -import fs from 'fs-extra'; -import checkVersionAvailability from '../utils/checkversionavailability.js'; - -/** - * Npm sometimes throws incorrect error 409 while publishing, while the package uploads correctly. - * The purpose of the script is to validate if packages that threw 409 are uploaded correctly to npm. - * - * @param {object} options - * @param {string} options.packagesDirectory Relative path to a location of packages to release. - * @param {string} options.version Version of the current release. - * @param {function} options.onSuccess Callback fired when function is successful. - * @returns {Promise} - */ -export default async function verifyPackagesPublishedCorrectly( options ) { - process.emitWarning( - 'The `verifyPackagesPublishedCorrectly()` function is deprecated and will be removed in the upcoming release (v45). ' + - 'Its responsibility has been merged with `publishPackages()`.', - { - type: 'DeprecationWarning', - code: 'DEP0001', - detail: 'https://github.com/ckeditor/ckeditor5-dev/blob/master/DEPRECATIONS.md#dep0001-verifypackagespublishedcorrectly' - } - ); - - const { packagesDirectory, version, onSuccess } = options; - const packagesToVerify = await glob( upath.join( packagesDirectory, '*' ), { absolute: true } ); - const errors = []; - - if ( !packagesToVerify.length ) { - onSuccess( 'No packages found to check for upload error 409.' ); - - return; - } - - for ( const packageToVerify of packagesToVerify ) { - const packageJson = await fs.readJson( upath.join( packageToVerify, 'package.json' ) ); - - const isPackageVersionAvailable = await checkVersionAvailability( version, packageJson.name ); - - if ( isPackageVersionAvailable ) { - errors.push( packageJson.name ); - } else { - await fs.remove( packageToVerify ); - } - } - - if ( errors.length ) { - throw new Error( 'Packages that were uploaded incorrectly, and need manual verification:\n' + errors.join( '\n' ) ); - } - - onSuccess( 'All packages that returned 409 were uploaded correctly.' ); -} diff --git a/packages/ckeditor5-dev-release-tools/tests/index.js b/packages/ckeditor5-dev-release-tools/tests/index.js index 95a386969..3821a28d9 100644 --- a/packages/ckeditor5-dev-release-tools/tests/index.js +++ b/packages/ckeditor5-dev-release-tools/tests/index.js @@ -31,7 +31,6 @@ import { import executeInParallel from '../lib/utils/executeinparallel.js'; import validateRepositoryToRelease from '../lib/utils/validaterepositorytorelease.js'; import checkVersionAvailability from '../lib/utils/checkversionavailability.js'; -import verifyPackagesPublishedCorrectly from '../lib/tasks/verifypackagespublishedcorrectly.js'; import getNpmTagFromVersion from '../lib/utils/getnpmtagfromversion.js'; import isVersionPublishableForTag from '../lib/utils/isversionpublishablefortag.js'; import provideToken from '../lib/utils/providetoken.js'; @@ -50,7 +49,6 @@ vi.mock( '../lib/tasks/push' ); vi.mock( '../lib/tasks/publishpackages' ); vi.mock( '../lib/tasks/updateversions' ); vi.mock( '../lib/tasks/cleanuppackages' ); -vi.mock( '../lib/tasks/verifypackagespublishedcorrectly' ); vi.mock( '../lib/utils/versions' ); vi.mock( '../lib/utils/getnpmtagfromversion' ); vi.mock( '../lib/utils/changelog' ); @@ -243,13 +241,6 @@ describe( 'dev-release-tools/index', () => { } ); } ); - describe( 'verifyPackagesPublishedCorrectly()', () => { - it( 'should be a function', () => { - expect( verifyPackagesPublishedCorrectly ).to.be.a( 'function' ); - expect( index.verifyPackagesPublishedCorrectly ).to.equal( verifyPackagesPublishedCorrectly ); - } ); - } ); - describe( 'isVersionPublishableForTag()', () => { it( 'should be a function', () => { expect( isVersionPublishableForTag ).to.be.a( 'function' ); diff --git a/packages/ckeditor5-dev-release-tools/tests/tasks/verifypackagespublishedcorrectly.js b/packages/ckeditor5-dev-release-tools/tests/tasks/verifypackagespublishedcorrectly.js deleted file mode 100644 index ce59981c8..000000000 --- a/packages/ckeditor5-dev-release-tools/tests/tasks/verifypackagespublishedcorrectly.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { glob } from 'glob'; -import fs from 'fs-extra'; -import verifyPackagesPublishedCorrectly from '../../lib/tasks/verifypackagespublishedcorrectly.js'; -import checkVersionAvailability from '../../lib/utils/checkversionavailability.js'; - -vi.mock( 'fs-extra' ); -vi.mock( '../../lib/utils/checkversionavailability' ); -vi.mock( 'glob' ); - -describe( 'verifyPackagesPublishedCorrectly()', () => { - beforeEach( () => { - vi.spyOn( process, 'emitWarning' ).mockImplementation( () => { - } ); - vi.mocked( fs ).remove.mockResolvedValue(); - vi.mocked( fs ).readJson.mockResolvedValue(); - vi.mocked( glob ).mockResolvedValue( [] ); - vi.mocked( checkVersionAvailability ).mockResolvedValue(); - } ); - - it( 'should not verify packages if there are no packages in the release directory', async () => { - const packagesDirectory = '/workspace/ckeditor5/release/npm'; - const version = 'latest'; - const onSuccess = vi.fn(); - - await verifyPackagesPublishedCorrectly( { packagesDirectory, version, onSuccess } ); - - expect( onSuccess ).toHaveBeenCalledExactlyOnceWith( 'No packages found to check for upload error 409.' ); - expect( vi.mocked( checkVersionAvailability ) ).not.toHaveBeenCalled(); - } ); - - it( 'should verify packages and remove them from the release directory on if their version are already taken', async () => { - vi.mocked( glob ).mockResolvedValue( [ 'package1', 'package2' ] ); - vi.mocked( fs ).readJson - .mockResolvedValueOnce( { name: '@namespace/package1' } ) - .mockResolvedValueOnce( { name: '@namespace/package2' } ); - - const packagesDirectory = '/workspace/ckeditor5/release/npm'; - const version = 'latest'; - const onSuccess = vi.fn(); - - await verifyPackagesPublishedCorrectly( { packagesDirectory, version, onSuccess } ); - - expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( 'latest', '@namespace/package1' ); - expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( 'latest', '@namespace/package2' ); - expect( vi.mocked( fs ).remove ).toHaveBeenCalledWith( 'package1' ); - expect( vi.mocked( fs ).remove ).toHaveBeenCalledWith( 'package2' ); - - expect( onSuccess ).toHaveBeenCalledExactlyOnceWith( 'All packages that returned 409 were uploaded correctly.' ); - } ); - - it( 'should not remove package from release directory when package is not available on npm', async () => { - vi.mocked( glob ).mockResolvedValue( [ 'package1', 'package2' ] ); - vi.mocked( fs ).readJson - .mockResolvedValueOnce( { name: '@namespace/package1' } ) - .mockResolvedValueOnce( { name: '@namespace/package2' } ); - vi.mocked( checkVersionAvailability ) - .mockResolvedValueOnce( true ) - .mockResolvedValueOnce( false ); - - const packagesDirectory = '/workspace/ckeditor5/release/npm'; - const version = 'latest'; - const onSuccess = vi.fn(); - - await expect( verifyPackagesPublishedCorrectly( { packagesDirectory, version, onSuccess } ) ) - .rejects.toThrow( 'Packages that were uploaded incorrectly, and need manual verification:\n@namespace/package1' ); - - expect( vi.mocked( fs ).remove ).toHaveBeenCalledExactlyOnceWith( 'package2' ); - } ); - - it( 'should print a deprecation warning', async () => { - const packagesDirectory = '/workspace/ckeditor5/release/npm'; - const version = 'latest'; - const onSuccess = vi.fn(); - - await verifyPackagesPublishedCorrectly( { packagesDirectory, version, onSuccess } ); - - expect( vi.mocked( process.emitWarning ) ).toHaveBeenCalledExactlyOnceWith( - expect.any( String ), - expect.objectContaining( { - type: 'DeprecationWarning', - code: 'DEP0001', - detail: expect.stringContaining( 'dep0001' ) - } ) - ); - } ); -} ); diff --git a/packages/ckeditor5-dev-transifex/CHANGELOG.md b/packages/ckeditor5-dev-transifex/CHANGELOG.md deleted file mode 100644 index 3be3bc730..000000000 --- a/packages/ckeditor5-dev-transifex/CHANGELOG.md +++ /dev/null @@ -1,6 +0,0 @@ -Changelog -========= - -All changes in the package are documented in the main repository. See: https://github.com/ckeditor/ckeditor5-dev/blob/master/CHANGELOG.md. - -This package was extracted from [`@ckeditor/ckeditor5-dev-env`](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-env) in version `v31.1.13`. Its previous changelog can be found here: [ckeditor5-dev-env@31.1.13/CHANGELOG.md](https://github.com/ckeditor/ckeditor5-dev/blob/v31.1.13/packages/ckeditor5-dev-env/CHANGELOG.md). diff --git a/packages/ckeditor5-dev-transifex/LICENSE.md b/packages/ckeditor5-dev-transifex/LICENSE.md deleted file mode 100644 index 0de2d7a3a..000000000 --- a/packages/ckeditor5-dev-transifex/LICENSE.md +++ /dev/null @@ -1,16 +0,0 @@ -Software License Agreement -========================== - -Copyright (c) 2003-2024, [CKSource](http://cksource.com) Holding sp. z o.o. All rights reserved. - -Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). - -Sources of Intellectual Property Included in CKEditor ------------------------------------------------------ - -Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission. - -Trademarks ----------- - -**CKEditor** is a trademark of [CKSource](http://cksource.com) Holding sp. z o.o. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders. diff --git a/packages/ckeditor5-dev-transifex/README.md b/packages/ckeditor5-dev-transifex/README.md deleted file mode 100644 index f64e81601..000000000 --- a/packages/ckeditor5-dev-transifex/README.md +++ /dev/null @@ -1,17 +0,0 @@ -CKEditor 5 Transifex tools -========================== - -[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-dev-transifex.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-transifex) -[![CircleCI](https://circleci.com/gh/ckeditor/ckeditor5-dev.svg?style=shield)](https://app.circleci.com/pipelines/github/ckeditor/ckeditor5-dev?branch=master) - -Tasks used during development of [CKEditor 5](https://ckeditor.com). - -More information about development tools packages can be found at the following URL: . - -## Changelog - -See the [`CHANGELOG.md`](https://github.com/ckeditor/ckeditor5-dev/blob/master/packages/ckeditor5-dev-transifex/CHANGELOG.md) file. - -## License - -Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). For full details about the license, please check the `LICENSE.md` file. diff --git a/packages/ckeditor5-dev-transifex/lib/createpotfiles.js b/packages/ckeditor5-dev-transifex/lib/createpotfiles.js deleted file mode 100644 index b7c4eafc6..000000000 --- a/packages/ckeditor5-dev-transifex/lib/createpotfiles.js +++ /dev/null @@ -1,349 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import path from 'path'; -import fs from 'fs-extra'; -import { deleteSync } from 'del'; -import { logger as utilsLogger } from '@ckeditor/ckeditor5-dev-utils'; -import { findMessages } from '@ckeditor/ckeditor5-dev-translations'; -import { verifyProperties } from './utils.js'; - -const corePackageName = 'ckeditor5-core'; - -/** - * Collects i18n messages for all packages using source messages from `t()` calls - * and context files and saves them as POT files in the `build/.transifex` directory. - * - * @param {object} options - * @param {Array.} options.sourceFiles An array of source files that contain messages to translate. - * @param {Array.} 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. - * @param {string} options.translationsDirectory An absolute path to the directory where the results should be saved. - * @param {boolean} [options.ignoreUnusedCorePackageContexts=false] Whether to hide unused context errors related to - * the `@ckeditor/ckeditor5-core` package. - * @param {boolean} [options.skipLicenseHeader=false] Whether to skip the license header in created `*.pot` files. - * @param {Logger} [options.logger] A logger. - */ -export default function createPotFiles( options ) { - const defaultLogger = utilsLogger(); - const langContextSuffix = path.join( 'lang', 'contexts.json' ); - - verifyProperties( options, [ 'sourceFiles', 'packagePaths', 'corePackagePath', 'translationsDirectory' ] ); - - const { - sourceFiles, - packagePaths, - corePackagePath, - translationsDirectory, - ignoreUnusedCorePackageContexts = false, - skipLicenseHeader = false, - logger = defaultLogger - } = options; - - const packageContexts = getPackageContexts( packagePaths, corePackagePath, langContextSuffix ); - const sourceMessages = collectSourceMessages( { sourceFiles, logger } ); - - const errors = [].concat( - assertNoMissingContext( { packageContexts, sourceMessages } ), - assertAllContextUsed( { packageContexts, sourceMessages, ignoreUnusedCorePackageContexts, corePackagePath, langContextSuffix } ), - assertNoRepeatedContext( { packageContexts } ) - ); - - for ( const error of errors ) { - logger.error( error ); - process.exitCode = 1; - } - - removeExistingPotFiles( translationsDirectory ); - - for ( const { packageName, content } of packageContexts.values() ) { - // Skip generating packages for the core package if the core package was not - // added to the list of packages. - if ( packageName === corePackageName && !packagePaths.includes( corePackagePath ) ) { - continue; - } - - // Create message from source messages and corresponding contexts. - const messages = Object.keys( content ).map( messageId => { - return Object.assign( - { context: content[ messageId ] }, - sourceMessages.find( message => message.id === messageId ) - ); - } ); - - const potFileContent = createPotFileContent( messages ); - const fileContent = skipLicenseHeader ? potFileContent : createPotFileHeader() + potFileContent; - - savePotFile( { - packageName, - fileContent, - logger, - translationsDirectory - } ); - } -} - -/** - * Traverses all packages and returns a map of all found language contexts - * (file content and file name). - * - * @param {Array.} packagePaths An array of paths to packages, which will be used to find message contexts. - * @returns {Map.} - */ -function getPackageContexts( packagePaths, corePackagePath, langContextSuffix ) { - // Add path to core package if not included in the package paths. - if ( !packagePaths.includes( corePackagePath ) ) { - packagePaths = [ ...packagePaths, corePackagePath ]; - } - - const mapEntries = packagePaths - .filter( packagePath => containsContextFile( packagePath, langContextSuffix ) ) - .map( packagePath => { - const pathToContext = path.join( packagePath, langContextSuffix ); - const packageName = packagePath.split( /[\\/]/ ).pop(); - - return [ - packageName, { - filePath: pathToContext, - content: JSON.parse( fs.readFileSync( pathToContext, 'utf-8' ) ), - packagePath, - packageName - } - ]; - } ); - - return new Map( mapEntries ); -} - -/** - * Returns an array of i18n source messages found in all source files. - * - * @returns {Array.} - */ -function collectSourceMessages( { sourceFiles, logger } ) { - const messages = []; - - for ( const sourceFile of sourceFiles ) { - const fileContent = fs.readFileSync( sourceFile, 'utf-8' ); - - messages.push( - ...getSourceMessagesFromFile( { filePath: sourceFile, fileContent, logger } ) - ); - } - - return messages; -} - -/** - * @param {object} options - * @param {Map.} options.packageContexts A map of language contexts. - * @param {Array.} options.sourceMessages An array of i18n source messages. - * @returns {Array.} - */ -function assertNoMissingContext( { packageContexts, sourceMessages } ) { - const errors = []; - const contextIdOrigins = new Map(); - - for ( const [ packageName, { content } ] of packageContexts ) { - for ( const messageId in content ) { - contextIdOrigins.set( messageId, packageName ); - } - } - - for ( const sourceMessage of sourceMessages ) { - if ( !contextIdOrigins.has( sourceMessage.id ) ) { - errors.push( `Context for the message id is missing ('${ sourceMessage.id }' from ${ sourceMessage.filePath }).` ); - } - } - - return errors; -} - -/** - * @param {object} options - * @param {Map.} options.packageContexts A map of language contexts. - * @param {Array.} options.sourceMessages An array of i18n source messages. - * @returns {Array.} - */ -function assertAllContextUsed( options ) { - const { packageContexts, sourceMessages, ignoreUnusedCorePackageContexts, corePackagePath, langContextSuffix } = options; - - const usedContextMap = new Map(); - const errors = []; - - // TODO - Message id might contain the `/` character. - - for ( const [ packageName, context ] of packageContexts ) { - // Ignore errors from the `@ckeditor/ckeditor5-core` package. - if ( ignoreUnusedCorePackageContexts && context.packagePath.includes( corePackagePath ) ) { - continue; - } - - for ( const id in context.content ) { - usedContextMap.set( packageName + '/' + id, false ); - } - } - - for ( const message of sourceMessages ) { - usedContextMap.set( message.packageName + '/' + message.id, true ); - usedContextMap.set( corePackageName + '/' + message.id, true ); - } - - for ( const [ id, used ] of usedContextMap ) { - // TODO - splitting by the `/` char is risky. - const packageNameParts = id.split( '/' ); - const messageId = packageNameParts.pop(); - - const contextFilePath = path.join( ...packageNameParts, langContextSuffix ); - - if ( !used ) { - errors.push( `Unused context: '${ messageId }' in ${ contextFilePath }` ); - } - } - - return errors; -} - -/** - * @param {object} options - * @param {Map.} options.packageContexts A map of language contexts. - * @returns {Array.} - */ -function assertNoRepeatedContext( { packageContexts } ) { - const errors = []; - const idOrigins = new Map(); - - for ( const context of packageContexts.values() ) { - for ( const id in context.content ) { - if ( idOrigins.has( id ) ) { - errors.push( `Context is duplicated for the id: '${ id }' in ${ context.filePath } and ${ idOrigins.get( id ) }.` ); - } - - idOrigins.set( id, context.filePath ); - } - } - - return errors; -} - -function removeExistingPotFiles( translationsDirectory ) { - deleteSync( translationsDirectory ); -} - -/** - * Creates a POT file for the given package and POT file content. - * The default place is `build/.transifex/[packageName]/en.pot`. - * - * @param {object} options - * @param {Logger} options.logger - * @param {string} options.packageName - * @param {string} options.translationsDirectory - * @param {string} options.fileContent - */ -function savePotFile( { packageName, fileContent, translationsDirectory, logger } ) { - const outputFilePath = path.join( translationsDirectory, packageName, 'en.pot' ); - - fs.outputFileSync( outputFilePath, fileContent ); - - logger.info( `Created file: ${ outputFilePath }.` ); -} - -/** - * Creates a POT file header. - * - * @returns {string} - */ -function createPotFileHeader() { - const year = new Date().getFullYear(); - - return `# Copyright (c) 2003-${ year }, CKSource Holding sp. z o.o. All rights reserved.\n\n`; -} - -/** - * Returns source messages found in the given file with additional data (`filePath` and `packageName`). - * - * @param {string} filePath - * @param {string} fileContent - * @returns {Array.} - */ -function getSourceMessagesFromFile( { filePath, fileContent, logger } ) { - const packageMatch = filePath.match( /([^/\\]+)[/\\]src[/\\]/ ); - const sourceMessages = []; - - const onErrorCallback = err => { - logger.error( err ); - process.exitCode = 1; - }; - - findMessages( fileContent, filePath, message => { - sourceMessages.push( Object.assign( { - filePath, - packageName: packageMatch[ 1 ] - }, message ) ); - }, onErrorCallback ); - - return sourceMessages; -} - -/** - * Creates a POT file from the given i18n messages. - * - * @param {Array.} messages - * @returns {string} - */ -function createPotFileContent( messages ) { - return messages.map( message => { - const potFileMessageEntry = []; - - // Note the usage of `JSON.stringify()` instead of `"` + `"`. - // It's because the message can contain an apostrophe. - // Note also that the order is important. - - if ( message.context ) { - potFileMessageEntry.push( `msgctxt ${ JSON.stringify( message.context ) }` ); - } - - potFileMessageEntry.push( `msgid ${ JSON.stringify( message.id ) }` ); - - if ( message.plural ) { - potFileMessageEntry.push( `msgid_plural ${ JSON.stringify( message.plural ) }` ); - potFileMessageEntry.push( `msgstr[0] ${ JSON.stringify( message.string ) }` ); - potFileMessageEntry.push( `msgstr[1] ${ JSON.stringify( message.plural ) }` ); - } else { - potFileMessageEntry.push( `msgstr ${ JSON.stringify( message.string ) }` ); - } - - return potFileMessageEntry - .map( x => x + '\n' ) - .join( '' ); - } ).join( '\n' ); -} - -/** - * @param {string} packageDirectory - */ -function containsContextFile( packageDirectory, langContextSuffix ) { - return fs.existsSync( path.join( packageDirectory, langContextSuffix ) ); -} - -/** - * @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} filePath A path to the context file. - * @property {object} content The context file content - a map of messageId->messageContext records. - * @property {string} packagePath The owner of the context file. - * @property {string} packageName The owner package name. - */ diff --git a/packages/ckeditor5-dev-transifex/lib/data/index.js b/packages/ckeditor5-dev-transifex/lib/data/index.js deleted file mode 100644 index c00283406..000000000 --- a/packages/ckeditor5-dev-transifex/lib/data/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { default as _languageCodeMap } from './languagecodemap.json' with { type: 'json' }; - -export const languageCodeMap = _languageCodeMap; diff --git a/packages/ckeditor5-dev-transifex/lib/data/languagecodemap.json b/packages/ckeditor5-dev-transifex/lib/data/languagecodemap.json deleted file mode 100644 index 8fb0b42a4..000000000 --- a/packages/ckeditor5-dev-transifex/lib/data/languagecodemap.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "be_BY": "be-by", - "en_AU": "en-au", - "de_CH": "de-ch", - "en_CA": "en-ca", - "en_GB": "en-gb", - "es_CO": "es-co", - "fr_CA": "fr-ca", - "ne_NP": "ne", - "pt_BR": "pt-br", - "ru@petr1708": "ru-petr1708", - "si_LK": "si", - "sr@latin": "sr-latn", - "ta_LK": "ta", - "zh_CN": "zh-cn", - "zh_HK": "zh-hk", - "zh_TW": "zh" -} diff --git a/packages/ckeditor5-dev-transifex/lib/download.js b/packages/ckeditor5-dev-transifex/lib/download.js deleted file mode 100644 index 1d26b5b50..000000000 --- a/packages/ckeditor5-dev-transifex/lib/download.js +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import path from 'path'; -import fs from 'fs-extra'; -import chalk from 'chalk'; -import { tools } from '@ckeditor/ckeditor5-dev-utils'; -import { cleanPoFileContent, createDictionaryFromPoFileContent } from '@ckeditor/ckeditor5-dev-translations'; -import transifexService from './transifexservice.js'; -import { verifyProperties, createLogger } from './utils.js'; -import { languageCodeMap } from './data/index.js'; - -/** - * Downloads translations from the Transifex for each localizable package. It creates `*.po` files out of the translations and replaces old - * translations with the downloaded ones. If not all translations have been downloaded successfully, the `.transifex-failed-downloads.json` - * file is created, containing information about the packages and languages for which the translations could not be downloaded. This file is - * then used next time this script is run: it will try to download translations only for packages and languages that failed previously. - * - * @param {object} config - * @param {string} config.organizationName Name of the organization to which the project belongs. - * @param {string} config.projectName Name of the project for downloading the translations. - * @param {string} config.token Token to the Transifex API. - * @param {Map.} config.packages A resource name -> package path map for which translations should be downloaded. - * The resource name must be the same as the name used in the Transifex service. The package path could be any local path fragment, where - * the downloaded translation will be stored. The final path for storing the translations is a combination of the `config.cwd` with the - * mentioned package path and the `lang/translations` subdirectory. - * @param {string} config.cwd Current work directory. - * @param {boolean} [config.simplifyLicenseHeader=false] Whether to skip adding the contribute guide URL in the output `*.po` files. - */ -export default async function downloadTranslations( config ) { - const logger = createLogger(); - - verifyProperties( config, [ 'organizationName', 'projectName', 'token', 'packages', 'cwd' ] ); - - transifexService.init( config.token ); - - logger.progress( 'Fetching project information...' ); - - const localizablePackageNames = [ ...config.packages.keys() ]; - const { resources, languages } = await transifexService.getProjectData( - config.organizationName, - config.projectName, - localizablePackageNames - ); - - const failedDownloads = []; - const { resourcesToProcess, isFailedDownloadFileAvailable } = getResourcesToProcess( { cwd: config.cwd, resources, languages } ); - - if ( isFailedDownloadFileAvailable ) { - logger.warning( 'Found the file containing a list of packages that failed during the last script execution.' ); - logger.warning( 'The script will process only packages listed in the file instead of all passed as "config.packages".' ); - - logger.progress( 'Downloading only translations that failed previously...' ); - } else { - logger.progress( 'Downloading all translations...' ); - } - - for ( const { resource, languages } of resourcesToProcess ) { - const packageName = transifexService.getResourceName( resource ); - const packagePath = config.packages.get( packageName ); - const pathToTranslations = path.join( config.cwd, packagePath, 'lang', 'translations' ); - const spinner = tools.createSpinner( `Processing "${ packageName }"...`, { indentLevel: 1, emoji: '👉' } ); - - spinner.start(); - - // Remove all old translations before saving new ones, but only if previously the download procedure has been finished without any - // failures. Otherwise, the current download procedure only tries to fetch the previously failed translations, so no existing files - // are removed beforehand. - if ( !isFailedDownloadFileAvailable ) { - fs.removeSync( pathToTranslations ); - } - - const { translations, failedDownloads: failedDownloadsForPackage } = await transifexService.getTranslations( resource, languages ); - - failedDownloads.push( ...failedDownloadsForPackage ); - - const savedFiles = saveNewTranslations( { - pathToTranslations, - translations, - simplifyLicenseHeader: config.simplifyLicenseHeader - } ); - - let statusMessage; - - if ( failedDownloadsForPackage.length ) { - statusMessage = `Saved ${ savedFiles } "*.po" file(s). ${ failedDownloadsForPackage.length } requests failed.`; - spinner.finish( { emoji: '❌' } ); - } else { - statusMessage = `Saved ${ savedFiles } "*.po" file(s).`; - spinner.finish(); - } - - logger.info( ' '.repeat( 6 ) + chalk.gray( statusMessage ) ); - } - - updateFailedDownloads( { cwd: config.cwd, failedDownloads } ); - - if ( failedDownloads.length ) { - logger.warning( 'Not all translations were downloaded due to errors in Transifex API.' ); - logger.warning( `Review the "${ chalk.underline( getPathToFailedDownloads( config.cwd ) ) }" file for more details.` ); - logger.warning( 'Re-running the script will process only packages specified in the file.' ); - } else { - logger.progress( 'Saved all translations.' ); - } -} - -/** - * Saves all valid translations on the filesystem. For each translation entry: - * - * (1) Check if the content is a translation. Skip processing current entry if it cannot be converted to a PO file. - * (2) Check if the language code should be mapped to another string on the filesystem. - * (3) Prepare the translation for storing on the filesystem: remove personal data and add a banner with information how to contribute. - * - * @param {object} config - * @param {string} config.pathToTranslations Path to translations. - * @param {Map.} config.translations The translation map: language code -> translation content. - * @param {boolean} config.simplifyLicenseHeader Whether to skip adding the contribute guide URL in the output `*.po` files. - * @returns {number} Number of saved files. - */ -function saveNewTranslations( { pathToTranslations, translations, simplifyLicenseHeader } ) { - let savedFiles = 0; - - for ( let [ lang, poFileContent ] of translations ) { - if ( !isPoFileContainingTranslations( poFileContent ) ) { - continue; - } - - if ( lang in languageCodeMap ) { - lang = languageCodeMap[ lang ]; - } - - poFileContent = cleanPoFileContent( poFileContent, { simplifyLicenseHeader } ); - - const pathToSave = path.join( pathToTranslations, lang + '.po' ); - - fs.outputFileSync( pathToSave, poFileContent ); - savedFiles++; - } - - return savedFiles; -} - -/** - * Based on whether previous download procedure has been finished without any failures, returns a collection of package names and language - * codes, for which translations will be downloaded: - * - * (1) If previous download procedure ended successfully, all translations for all resources will be downloaded. - * (2) Otherwise, only packages and their failed translation downloads defined in `.transifex-failed-downloads.json` are taken into account. - * - * @param {object} config - * @param {string} config.cwd Current work directory. - * @param {Array.} config.resources All found resource instances for which translations could be downloaded. - * @param {Array.} config.languages All found language instances in the project. - * @returns {object} result - * @returns {boolean} result.isFailedDownloadFileAvailable Indicates whether previous download procedure did not fetch all translations. - * @returns {Array.} result.resourcesToProcess Resource instances and their associated language instances to use during downloading - * the translations. - */ -function getResourcesToProcess( { cwd, resources, languages } ) { - const pathToFailedDownloads = getPathToFailedDownloads( cwd ); - const isFailedDownloadFileAvailable = fs.existsSync( pathToFailedDownloads ); - - if ( !isFailedDownloadFileAvailable ) { - return { - isFailedDownloadFileAvailable, - resourcesToProcess: resources.map( resource => ( { resource, languages } ) ) - }; - } - - const resourcesMap = new Map( [ - ...resources.map( resource => [ transifexService.getResourceName( resource ), resource ] ) - ] ); - - const languagesMap = new Map( [ - ...languages.map( language => [ transifexService.getLanguageCode( language ), language ] ) - ] ); - - return { - isFailedDownloadFileAvailable, - resourcesToProcess: fs.readJsonSync( pathToFailedDownloads ) - .map( item => ( { - resource: resourcesMap.get( item.resourceName ), - languages: item.languages - .filter( language => languagesMap.has( language.code ) ) - .map( language => languagesMap.get( language.code ) ) - } ) ) - .filter( item => item.resource && item.languages.length ) - }; -} - -/** - * Saves all the failed downloads to `.transifex-failed-downloads.json` file. If there are no failures, the file is removed. - * - * @param {object} config - * @param {string} config.cwd Current work directory. - * @param {Array.} config.failedDownloads Collection of all the failed downloads. - */ -function updateFailedDownloads( { cwd, failedDownloads } ) { - const pathToFailedDownloads = getPathToFailedDownloads( cwd ); - - if ( failedDownloads.length ) { - const groupedFailedDownloads = failedDownloads.reduce( ( result, failedDownload ) => { - const failedPackage = result.get( failedDownload.resourceName ) || { - resourceName: failedDownload.resourceName, - languages: [] - }; - - failedPackage.languages.push( { - code: failedDownload.languageCode, - errorMessage: failedDownload.errorMessage - } ); - - return result.set( failedDownload.resourceName, failedPackage ); - }, new Map() ); - - fs.writeJsonSync( pathToFailedDownloads, [ ...groupedFailedDownloads.values() ], { spaces: 2 } ); - } else { - fs.removeSync( pathToFailedDownloads ); - } -} - -/** - * Checks if the received data is a translation. - * - * @param {string} poFileContent Received data. - * @returns {boolean} - */ -function isPoFileContainingTranslations( poFileContent ) { - const translations = createDictionaryFromPoFileContent( poFileContent ); - - return Object.keys( translations ) - .some( msgId => translations[ msgId ] !== '' ); -} - -/** - * Returns an absolute path to the file containing failed downloads. - * - * @param {string} cwd Current working directory. - * @returns {string} - */ -function getPathToFailedDownloads( cwd ) { - return path.join( cwd, '.transifex-failed-downloads.json' ); -} diff --git a/packages/ckeditor5-dev-transifex/lib/gettoken.js b/packages/ckeditor5-dev-transifex/lib/gettoken.js deleted file mode 100644 index 6db12f86d..000000000 --- a/packages/ckeditor5-dev-transifex/lib/gettoken.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import inquirer from 'inquirer'; - -/** - * Takes username and password from prompt and returns promise that resolves with object that contains them. - * - * @returns {Promise.} - */ -export default async function getToken() { - const { token } = await inquirer.prompt( [ { - type: 'password', - message: 'Provide the Transifex token (generate it here: https://www.transifex.com/user/settings/api/):', - name: 'token' - } ] ); - - return token; -} diff --git a/packages/ckeditor5-dev-transifex/lib/index.js b/packages/ckeditor5-dev-transifex/lib/index.js deleted file mode 100644 index b912148a8..000000000 --- a/packages/ckeditor5-dev-transifex/lib/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import * as _transifexUtils from './utils.js'; - -export const transifexUtils = _transifexUtils; - -export { default as createPotFiles } from './createpotfiles.js'; -export { default as uploadPotFiles } from './upload.js'; -export { default as downloadTranslations } from './download.js'; -export { default as getToken } from './gettoken.js'; -export { default as transifexService } from './transifexservice.js'; diff --git a/packages/ckeditor5-dev-transifex/lib/transifexservice.js b/packages/ckeditor5-dev-transifex/lib/transifexservice.js deleted file mode 100644 index e74eeb70b..000000000 --- a/packages/ckeditor5-dev-transifex/lib/transifexservice.js +++ /dev/null @@ -1,450 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { transifexApi } from '@transifex/api'; - -const MAX_REQUEST_ATTEMPTS = 10; -const REQUEST_RETRY_TIMEOUT = 3000; // In milliseconds. - -// It may happen that sending several dozen requests at the same time fails due to the limitations in the operating system on the network -// stack. Therefore, each request is delayed by the number of milliseconds defined below. -const REQUEST_START_OFFSET_TIMEOUT = 100; - -/** - * Promise wrappers of the Transifex API v3.0. - * - * @see https://docs.transifex.com/api-3-0/introduction-to-api-3-0 for API documentation. - */ -export default { - init, - getProjectData, - getTranslations, - getResourceName, - getLanguageCode, - isSourceLanguage, - createResource, - createSourceFile, - getResourceUploadDetails, - getResourceTranslations -}; - -/** - * Configures the API token for Transifex service if it has not been set yet. - * - * @param {string} token Token for the Transifex API. - */ -function init( token ) { - if ( !transifexApi.auth ) { - transifexApi.setup( { auth: token } ); - } -} - -/** - * Creates a new resource on Transifex. - * - * @param {object} options - * @param {string} options.organizationName The name of the organization to which the project belongs. - * @param {string} options.projectName The name of the project for creating the resource. - * @param {string} options.resourceName The name of the resource to create. - * @returns {Promise} - */ -async function createResource( options ) { - const { organizationName, projectName, resourceName } = options; - const requestParams = { - name: resourceName, - slug: resourceName, - relationships: { - i18n_format: { - data: { - id: 'PO', - type: 'i18n_formats' - } - }, - project: { - data: { - id: `o:${ organizationName }:p:${ projectName }`, - type: 'projects' - } - } - } - }; - - return transifexApi.Resource.create( requestParams ); -} - -/** - * Uploads a new translations source for the specified resource (package). - * - * @param {object} options - * @param {string} options.organizationName The name of the organization to which the project belongs. - * @param {string} options.projectName The name of the project for uploading the translations entries. - * @param {string} options.resourceName The The name of resource. - * @param {string} options.content A content of the `*.po` file containing source for translations. - * @returns {Promise.} - */ -async function createSourceFile( options ) { - const { organizationName, projectName, resourceName, content } = options; - const requestData = { - attributes: { - content, - content_encoding: 'text' - }, - relationships: { - resource: { - data: { - id: `o:${ organizationName }:p:${ projectName }:r:${ resourceName }`, - type: 'resources' - } - } - }, - type: 'resource_strings_async_uploads' - }; - - return transifexApi.ResourceStringsAsyncUpload.create( requestData ) - .then( response => response.id ); -} - -/** - * Resolves a promise containing an object with a summary of processing the uploaded source - * file created by the Transifex service if the upload task is completed. - * - * @param {string} uploadId - * @param {number} [numberOfAttempts=1] A number containing a current attempt. - * @returns {Promise} - */ -async function getResourceUploadDetails( uploadId, numberOfAttempts = 1 ) { - return transifexApi.ResourceStringsAsyncUpload.get( uploadId ) - .then( statusResponse => { - const status = statusResponse.attributes.status; - const isPending = status === 'pending' || status === 'processing'; - - if ( !isPending ) { - return statusResponse; - } - - if ( numberOfAttempts === MAX_REQUEST_ATTEMPTS ) { - // Rejects with an object that looks like the `JsonApi` error produced by the Transifex API. - return Promise.reject( { - errors: [ - { detail: 'Failed to retrieve the upload details.' } - ] - } ); - } - return wait( REQUEST_RETRY_TIMEOUT ) - .then( () => getResourceUploadDetails( uploadId, numberOfAttempts + 1 ) ); - } ); -} - -/** - * Retrieves all the resources and languages associated with the requested project within given organization from the Transifex service. - * - * @param {string} organizationName Name of the organization to which the project belongs. - * @param {string} projectName Name of the project for downloading the translations. - * @param {Array.} localizablePackageNames Names of all packages for which translations should be downloaded. - * @returns {Promise.} result - * @returns {Array.} result.resources All found resource instances for which translations could be downloaded. - * @returns {Array.} result.languages All found language instances in the project. - */ -async function getProjectData( organizationName, projectName, localizablePackageNames ) { - const organization = await transifexApi.Organization.get( { slug: organizationName } ); - const projects = await organization.fetch( 'projects' ); - const project = await projects.get( { slug: projectName } ); - const resources = await project.fetch( 'resources' ); - const languages = await project.fetch( 'languages' ); - - const resourcesArray = []; - const languagesArray = [ createSourceLanguage() ]; - - for await ( const resource of resources.all() ) { - const resourceName = getResourceName( resource ); - - if ( localizablePackageNames.includes( resourceName ) ) { - resourcesArray.push( resource ); - } - } - - for await ( const language of languages.all() ) { - languagesArray.push( language ); - } - - return { - resources: resourcesArray, - languages: languagesArray - }; -} - -/** - * Fetches all the translations and the language source file in the PO format for the given resource. - * The download procedure consists of the following steps: - * - * (1) Create the download requests for the language source file and all the translations. This download request triggers the Transifex - * service to start preparing the PO file. - * (2) Retrieve the target URL from every download request, where the status of the file being prepared for download can be checked. - * (3) Download the file from the target URL received in step (2). - * - * The download procedure is not interrupted when any request has failed for any reason, but continues until the end for each language. - * Failed requests and all fetched translations are collected and returned to the caller for further processing. - * - * @param {object} resource The resource instance for which translations should be downloaded. - * @param {Array.} languages An array of all the language instances found in the project. - * @returns {Promise.} result - * @returns {Map.} result.translations The translation map: language code -> translation content. - * @returns {Array.} result.failedDownloads Collection of all the failed downloads. - */ -async function getTranslations( resource, languages ) { - const downloadRequests = await Promise - .allSettled( - languages.map( async ( language, index ) => { - await wait( REQUEST_START_OFFSET_TIMEOUT * index ); - - return createDownloadRequest( resource, language ); - } ) ) - .then( results => ( { - failed: getFailedResults( results ), - successful: getSuccessfulResults( results ).map( result => { - const url = result.links.self; - const { resource, language = createSourceLanguage() } = result.related; - - return { - url, - resourceName: getResourceName( resource ), - languageCode: getLanguageCode( language ) - }; - } ) - } ) ); - - const translationRequests = await Promise - .allSettled( - downloadRequests.successful.map( async ( request, index ) => { - await wait( REQUEST_START_OFFSET_TIMEOUT * index ); - - return downloadFile( request ); - } ) - ) - .then( results => ( { - failed: getFailedResults( results ), - successful: getSuccessfulResults( results ) - } ) ); - - return { - translations: new Map( translationRequests.successful ), - failedDownloads: [ - ...downloadRequests.failed, - ...translationRequests.failed - ] - }; -} - -/** - * Fetches all the translations for the specified resource and language. The returned array contains translation items (objects) with - * attributes and relationships to other Transifex entities. - * - * @param {string} resourceId The resource id for which translation should be downloaded. - * @param {string} languageId The language id for which translation should be downloaded. - * @returns {Promise.>} - */ -async function getResourceTranslations( resourceId, languageId ) { - const translations = transifexApi.ResourceTranslation - .filter( { resource: resourceId, language: languageId } ) - .include( 'resource_string' ); - - // Returned translations might be paginated, so return the whole collection. - let page = translations; - const results = []; - - await page.fetch(); - - while ( true ) { - for ( const item of page.data ) { - results.push( item ); - } - - if ( !page.next ) { - break; - } - - page = await page.getNext(); - } - - return results; -} - -/** - * Creates the download request for the given resource and the language. - * - * @param {object} resource The resource instance for which translation should be downloaded. - * @param {object} language The language instance for which translation should be downloaded. - * @param {number} [numberOfAttempts=1] Current number of request attempt. - * @returns {Promise.} - */ -function createDownloadRequest( resource, language, numberOfAttempts = 1 ) { - const attributes = { - callback_url: null, - content_encoding: 'text', - file_type: 'default', - pseudo: false - }; - - const relationships = isSourceLanguage( language ) ? { resource } : { resource, language }; - const requestName = isSourceLanguage( language ) ? 'ResourceStringsAsyncDownload' : 'ResourceTranslationsAsyncDownload'; - const requestType = isSourceLanguage( language ) ? 'resource_strings_async_downloads' : 'resource_translations_async_downloads'; - - return transifexApi[ requestName ] - .create( { - attributes, - relationships, - type: requestType - } ) - .catch( async () => { - if ( numberOfAttempts === MAX_REQUEST_ATTEMPTS ) { - const resourceName = getResourceName( resource ); - const languageCode = getLanguageCode( language ); - - return Promise.reject( { - resourceName, - languageCode, - errorMessage: 'Failed to create download request.' - } ); - } - - await wait( REQUEST_RETRY_TIMEOUT ); - - return createDownloadRequest( resource, language, numberOfAttempts + 1 ); - } ); -} - -/** - * Tries to fetch the file, up to the MAX_REQUEST_ATTEMPTS times, with the REQUEST_RETRY_TIMEOUT milliseconds timeout between each - * attempt. There are three possible cases that are handled during downloading a file: - * - * (1) According to the Transifex API v3.0, when the requested file is ready for download, the Transifex service returns HTTP code 303, - * which is the redirection to the new location, where the file is available. By default, `fetch` follows redirections so the requested - * file is downloaded automatically. - * (2) If the requested file is not ready yet, but the response status from the Transifex service was successful and the number of retries - * has not reached the limit yet, the request is queued and retried after the REQUEST_RETRY_TIMEOUT timeout. - * (3) Otherwise, there is either a problem with downloading a file (the request has failed) or the number of retries has reached the limit, - * so rejected promise is returned. - * - * @param {object} downloadRequest Data that defines the requested file. - * @param {string} downloadRequest.url URL where generated PO file will be available to download. - * @param {string} downloadRequest.resourceName Package name for which the URL is generated. - * @param {string} downloadRequest.languageCode Language code for which the URL is generated. - * @param {number} [numberOfAttempts=1] Current number of download attempt. - * @returns {Promise.>} The 2-element array: the language code at index 0 and the translation content at index 1. - */ -async function downloadFile( downloadRequest, numberOfAttempts = 1 ) { - const { url, resourceName, languageCode } = downloadRequest; - - const response = await fetch( url, { - method: 'GET', - headers: { - ...transifexApi.auth() - } - } ); - - if ( response.ok && response.redirected ) { - const translation = await response.text(); - - return [ languageCode, translation ]; - } - - if ( !response.ok || numberOfAttempts === MAX_REQUEST_ATTEMPTS ) { - let errorMessage = 'Failed to download the translation file. '; - - if ( !response.ok ) { - errorMessage += `Received response: ${ response.status } ${ response.statusText }`; - } else { - errorMessage += 'Requested file is not ready yet, but the limit of file download attempts has been reached.'; - } - - return Promise.reject( { - resourceName, - languageCode, - errorMessage - } ); - } - - await wait( REQUEST_RETRY_TIMEOUT ); - - return downloadFile( downloadRequest, numberOfAttempts + 1 ); -} - -/** - * Retrieves the resource name (the package name) from the resource instance. - * - * @param {object} resource Resource instance. - * @returns {string} - */ -function getResourceName( resource ) { - return resource.attributes.slug; -} - -/** - * Retrieves the language code from the language instance. - * - * @param {object} language Language instance. - * @returns {string} - */ -function getLanguageCode( language ) { - return language.attributes.code; -} - -/** - * Creates an artificial Transifex language instance for the source language, which is English. The language instance for the source strings - * is needed, because Transifex service has two dedicated API resources: one for the translations and another one for the source strings. - * - * @returns {object} - */ -function createSourceLanguage() { - return { - attributes: { - code: 'en' - } - }; -} - -/** - * Checks if the language instance is the source language, which is English. - * - * @param {object} language Language instance. - * @returns {boolean} - */ -function isSourceLanguage( language ) { - return getLanguageCode( language ) === 'en'; -} - -/** - * Returns results from each rejected promise, which are returned from the `Promise.allSettled()` method. - * - * @param {Array.} results Collection of objects that each describes the outcome of each promise. - * @returns {Array.<*>} - */ -function getFailedResults( results ) { - return results - .filter( result => result.status === 'rejected' ) - .map( result => result.reason ); -} - -/** - * Returns results from each fulfilled promise, which are returned from the `Promise.allSettled()` method. - * - * @param {Array.} results Collection of objects that each describes the outcome of each promise. - * @returns {Array.<*>} - */ -function getSuccessfulResults( results ) { - return results - .filter( result => result.status === 'fulfilled' ) - .map( result => result.value ); -} - -/** - * Simple promisified timeout that resolves after defined number of milliseconds. - * - * @param {number} numberOfMilliseconds Number of milliseconds after which the promise will be resolved. - * @returns {Promise} - */ -function wait( numberOfMilliseconds ) { - return new Promise( resolve => setTimeout( resolve, numberOfMilliseconds ) ); -} diff --git a/packages/ckeditor5-dev-transifex/lib/upload.js b/packages/ckeditor5-dev-transifex/lib/upload.js deleted file mode 100644 index 6deb7c143..000000000 --- a/packages/ckeditor5-dev-transifex/lib/upload.js +++ /dev/null @@ -1,309 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import fs from 'fs/promises'; -import path from 'path'; -import Table from 'cli-table'; -import chalk from 'chalk'; -import transifexService from './transifexservice.js'; -import { verifyProperties, createLogger } from './utils.js'; -import { tools } from '@ckeditor/ckeditor5-dev-utils'; - -const RESOURCE_REGEXP = /r:(?[a-z0-9_-]+)$/i; - -/** - * Uploads translations to the Transifex. - * - * The process for each package looks like the following: - * * If a package does not exist on Tx - create it. - * * Upload a new source file for the given package (resource). - * * Verify Tx response. - * - * At the end, the script displays a summary table. - * - * The Transifex API may end with an error at any stage. In such a case, the resource is not processed anymore. - * It is saved to a file (`.transifex-failed-uploads.json`). Rerunning the script will process only packages specified in the file. - * - * @param {object} config - * @param {string} config.token Token to the Transifex API. - * @param {string} config.organizationName Name of the organization to which the project belongs. - * @param {string} config.projectName Name of the project for downloading the translations. - * @param {string} config.cwd Current work directory. - * @param {Map.} config.packages A resource name -> package path map for which translations should be uploaded. - * @returns {Promise} - */ -export default async function upload( config ) { - const TRANSIFEX_RESOURCE_ERRORS = {}; - - verifyProperties( config, [ 'token', 'organizationName', 'projectName', 'cwd', 'packages' ] ); - - const logger = createLogger(); - const pathToFailedUploads = path.join( config.cwd, '.transifex-failed-uploads.json' ); - const isFailedUploadFileAvailable = await isFile( pathToFailedUploads ); - - // When rerunning the task, it is an array containing names of packages that failed. - let failedPackages = null; - - // Check if rerunning the same task again. - if ( isFailedUploadFileAvailable ) { - logger.warning( 'Found the file containing a list of packages that failed during the last script execution.' ); - logger.warning( 'The script will process only packages listed in the file instead of all passed as "config.packages".' ); - - failedPackages = Object.keys( - ( await import( pathToFailedUploads ) ).default - ); - } - - logger.progress( 'Fetching project information...' ); - - transifexService.init( config.token ); - - const localizablePackageNames = [ ...config.packages.keys() ]; - const { organizationName, projectName } = config; - - // Find existing resources on Transifex. - const projectData = await transifexService.getProjectData( organizationName, projectName, localizablePackageNames ) - .catch( () => { - logger.error( `Cannot find project details for "${ organizationName }/${ projectName }".` ); - logger.error( 'Make sure you specified a valid auth token or an organization/project names.' ); - } ); - - if ( !projectData ) { - return Promise.resolve(); - } - - // Then, prepare a map containing all resources to upload. - // The map defines a path to the translation file for each package. - // Also, it knows whether the resource exists on Tx. If don't, a new one should be created. - const resourcesToUpload = new Map( - [ ...config.packages ].map( ( [ resourceName, relativePath ] ) => ( [ - resourceName, - { - potFile: path.join( config.cwd, relativePath, 'en.pot' ), - isNew: !projectData.resources.find( item => item.attributes.name === resourceName ) - } - ] ) ) - ); - - logger.progress( 'Uploading new translations...' ); - - const uploadIds = []; - - // For each package, create a new resource if needed, then upload the new source file with translations. - for ( const [ resourceName, { potFile, isNew } ] of resourcesToUpload ) { - // The package should be processed unless the previous run failed and isn't specified in the failed packages collection. - if ( failedPackages && !failedPackages.includes( resourceName ) ) { - continue; - } - - const spinner = tools.createSpinner( `Processing "${ resourceName }"`, { indentLevel: 1, emoji: '👉' } ); - spinner.start(); - - // For new packages, before uploading their translations, we need to create a dedicated resource. - if ( isNew ) { - await transifexService.createResource( { organizationName, projectName, resourceName } ) - .catch( errorHandlerFactory( TRANSIFEX_RESOURCE_ERRORS, resourceName, spinner ) ); - } - - // Abort if creating the resource ended with an error. - if ( hasError( TRANSIFEX_RESOURCE_ERRORS, resourceName ) ) { - continue; - } - - const content = await fs.readFile( potFile, 'utf-8' ); - - await transifexService.createSourceFile( { organizationName, projectName, resourceName, content } ) - .then( uuid => { - uploadIds.push( { resourceName, uuid } ); - spinner.finish(); - } ) - .catch( errorHandlerFactory( TRANSIFEX_RESOURCE_ERRORS, resourceName, spinner ) ); - } - - // An empty line for making a space between list of resources and the new process info. - logger.progress(); - - // Chalk supports chaining which is hard to mock in tests. Let's simplify it. - const takeWhileText = chalk.gray( 'It takes a while.' ); - const spinner = tools.createSpinner( chalk.cyan( 'Collecting responses...' ) + ' ' + chalk.italic( takeWhileText ) ); - - spinner.start(); - - const uploadDetails = uploadIds.map( async ( { resourceName, uuid } ) => { - return transifexService.getResourceUploadDetails( uuid ) - .catch( errorHandlerFactory( TRANSIFEX_RESOURCE_ERRORS, resourceName ) ); - } ); - - const summary = ( await Promise.all( uploadDetails ) ) - .filter( Boolean ) - .map( extractResourceDetailsFromTx( resourcesToUpload ) ) - .sort( sortResources() ) - .map( formatTableRow() ); - - spinner.finish(); - - logger.progress( 'Done.' ); - - if ( summary.length ) { - const table = new Table( { - head: [ 'Package name', 'Is new?', 'Added', 'Updated', 'Removed' ], - style: { compact: true } - } ); - - table.push( ...summary ); - - logger.info( table.toString() ); - } - - if ( hasError( TRANSIFEX_RESOURCE_ERRORS ) ) { - // An empty line for making a space between steps and the warning message. - logger.info( '' ); - logger.warning( 'Not all translations were uploaded due to errors in Transifex API.' ); - logger.warning( `Review the "${ chalk.underline( pathToFailedUploads ) }" file for more details.` ); - logger.warning( 'Re-running the script will process only packages specified in the file.' ); - - await fs.writeFile( pathToFailedUploads, JSON.stringify( TRANSIFEX_RESOURCE_ERRORS, null, 2 ) + '\n', 'utf-8' ); - } - // If the `.transifex-failed-uploads.json` file exists but the run has finished with no errors, - // remove the file as it is not required anymore. - else if ( isFailedUploadFileAvailable ) { - await fs.unlink( pathToFailedUploads ); - } -} - -/** - * Returns a factory function that process a response from Transifex and prepares a single resource - * to be displayed in the summary table. - * - * @param {Map} resourcesToUpload - * @returns {Function} - */ -function extractResourceDetailsFromTx( resourcesToUpload ) { - return response => { - const { resourceName } = response.related.resource.id.match( RESOURCE_REGEXP ).groups; - - const { isNew } = resourcesToUpload.get( resourceName ); - const created = response.attributes.details.strings_created; - const updated = response.attributes.details.strings_updated; - const deleted = response.attributes.details.strings_deleted; - - return { - resourceName, - isNew, - created, - updated, - deleted, - changes: created + updated + deleted - }; - }; -} - -/** - * Returns a function that sorts a list of resources with the following criteria: - * - * * New packages should be on top. - * * Then, packages containing changes. - * * Then, the rest of the packages (not ned and not containing changes). - * - * When all packages are grouped, they are sorted alphabetically. - * - * @returns {Function} - */ -function sortResources() { - return ( first, second ) => { - // Sort by "isNew". - if ( first.isNew && !second.isNew ) { - return -1; - } else if ( !first.isNew && second.isNew ) { - return 1; - } - - // Then, sort by "has changes". - if ( first.changes && !second.changes ) { - return -1; - } else if ( !first.changes && second.changes ) { - return 1; - } - - // Then, sort packages by their names. - return first.resourceName.localeCompare( second.resourceName ); - }; -} - -/** - * Returns a function that formats a row before displaying it. Each row contains five columns that - * represent the following data: - * - * (1) A resource name. - * (2) If the resource is new, the 🆕 emoji is displayed. - * (3) A number of added translations. - * (4) A number of modified translations (including removed). - * (5) A number of removed translations. - * - * Resources without changes are grayed out. - * - * @returns {Function} - */ -function formatTableRow() { - return ( { resourceName, isNew, created, updated, deleted, changes } ) => { - // Format a single row. - const data = [ resourceName, isNew ? '🆕' : '', created.toString(), updated.toString(), deleted.toString() ]; - - // For new resources or if it contains changes, use the default terminal color to print details. - if ( changes || isNew ) { - return data; - } - - // But if it doesn't, gray out. - return data.map( row => chalk.gray( row ) ); - }; -} - -/** - * Returns `true` if the database containing error for the specified `packageName`. - * - * If the `packageName` is not specified, returns `true` if any error occurs. - * - * @param {object} [TRANSIFEX_RESOURCE_ERRORS] - * @param {string|null} [packageName=null] - * @returns {boolean} - */ -function hasError( TRANSIFEX_RESOURCE_ERRORS, packageName = null ) { - if ( !packageName ) { - return Boolean( Object.keys( TRANSIFEX_RESOURCE_ERRORS ).length ); - } - - return Boolean( TRANSIFEX_RESOURCE_ERRORS[ packageName ] ); -} - -/** - * Creates a callback that stores errors from Transifex for the given `packageName`. - * - * @param {object} [TRANSIFEX_RESOURCE_ERRORS] - * @param {string} packageName - * @param {CKEditor5Spinner|null} [spinner=null] - * @returns {Function} - */ -function errorHandlerFactory( TRANSIFEX_RESOURCE_ERRORS, packageName, spinner ) { - return errorResponse => { - if ( spinner ) { - spinner.finish( { emoji: '❌' } ); - } - - // The script does not continue to process a package if it fails. - // Hence, we don't have to check do we override existing errors. - TRANSIFEX_RESOURCE_ERRORS[ packageName ] = errorResponse.errors.map( e => e.detail ); - }; -} - -/** - * @param {string} pathToFile - * @returns {Promise.} - */ -function isFile( pathToFile ) { - return fs.lstat( pathToFile ) - .then( () => true ) - .catch( () => false ); -} diff --git a/packages/ckeditor5-dev-transifex/lib/utils.js b/packages/ckeditor5-dev-transifex/lib/utils.js deleted file mode 100644 index 939ce35c8..000000000 --- a/packages/ckeditor5-dev-transifex/lib/utils.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import chalk from 'chalk'; -import { logger as loggerFactory } from '@ckeditor/ckeditor5-dev-utils'; - -/** - * Checks whether specified `properties` are specified in the `objectToCheck` object. - * - * Throws an error if any property is missing. - * - * @param {object} objectToCheck - * @param {Array.} properties - */ -export function verifyProperties( objectToCheck, properties ) { - const nonExistingProperties = properties.filter( property => objectToCheck[ property ] === undefined ); - - if ( nonExistingProperties.length ) { - throw new Error( `The specified object misses the following properties: ${ nonExistingProperties.join( ', ' ) }.` ); - } -} - -/** - * Creates logger instance. - * - * @returns {object} logger - * @returns {Function} logger.progress - * @returns {Function} logger.info - * @returns {Function} logger.warning - * @returns {Function} logger.error - */ -export function createLogger() { - const logger = loggerFactory(); - - return { - progress( message ) { - if ( !message ) { - this.info( '' ); - } else { - this.info( '\n📍 ' + chalk.cyan( message ) ); - } - }, - ...logger - }; -} diff --git a/packages/ckeditor5-dev-transifex/package.json b/packages/ckeditor5-dev-transifex/package.json deleted file mode 100644 index d0e9a1eb7..000000000 --- a/packages/ckeditor5-dev-transifex/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@ckeditor/ckeditor5-dev-transifex", - "version": "44.2.1", - "description": "Used to download and upload translations using the Transifex service.", - "keywords": [], - "author": "CKSource (http://cksource.com/)", - "license": "GPL-2.0-or-later", - "homepage": "https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-transifex", - "bugs": "https://github.com/ckeditor/ckeditor5/issues", - "repository": { - "type": "git", - "url": "https://github.com/ckeditor/ckeditor5-dev.git", - "directory": "packages/ckeditor5-dev-transifex" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=5.7.1" - }, - "main": "lib/index.js", - "type": "module", - "files": [ - "lib" - ], - "dependencies": { - "fs-extra": "^11.0.0", - "del": "^7.0.0", - "@ckeditor/ckeditor5-dev-utils": "^44.2.1", - "@ckeditor/ckeditor5-dev-translations": "^44.2.1", - "chalk": "^5.0.0", - "inquirer": "^11.0.0", - "@transifex/api": "^7.0.0", - "cli-table": "^0.3.1" - }, - "devDependencies": { - "vitest": "^2.0.5" - }, - "scripts": { - "test": "vitest run --config vitest.config.js", - "coverage": "vitest run --config vitest.config.js --coverage" - } -} diff --git a/packages/ckeditor5-dev-transifex/tests/createpotfiles.js b/packages/ckeditor5-dev-transifex/tests/createpotfiles.js deleted file mode 100644 index dec22caae..000000000 --- a/packages/ckeditor5-dev-transifex/tests/createpotfiles.js +++ /dev/null @@ -1,842 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import createPotFiles from '../lib/createpotfiles.js'; - -import { findMessages } from '@ckeditor/ckeditor5-dev-translations'; -import { verifyProperties } from '../lib/utils.js'; -import { deleteSync } from 'del'; -import fs from 'fs-extra'; -import path from 'path'; - -vi.mock( '../lib/utils.js' ); -vi.mock( '@ckeditor/ckeditor5-dev-translations' ); -vi.mock( 'del' ); -vi.mock( 'fs-extra' ); -vi.mock( 'path' ); - -describe( 'dev-transifex/createPotFiles()', () => { - let loggerMocks; - - beforeEach( () => { - loggerMocks = { - info: vi.fn(), - warning: vi.fn(), - error: vi.fn() - }; - - vi.mocked( verifyProperties ).mockImplementation( vi.fn() ); - vi.mocked( path.join ).mockImplementation( ( ...args ) => args.join( '/' ) ); - } ); - - it( 'should not create any POT file if no package is passed', () => { - createPotFiles( { - sourceFiles: [], - packagePaths: [], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 0 ); - } ); - - it( 'should delete the build directory before creating POT files', () => { - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( deleteSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( deleteSync ) ).toHaveBeenCalledWith( '/cwd/build/.transifex' ); - } ); - - it( 'should create a POT file entry for one message with a corresponding context', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - '/cwd/build/.transifex/ckeditor5-foo/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgstr "foo"', - '' - ].join( '\n' ) - ); - } ); - - it( 'should warn if the message context is missing', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 1 ); - expect( loggerMocks.error ).toHaveBeenCalledWith( - 'Context for the message id is missing (\'foo_id\' from packages/ckeditor5-foo/src/foo.js).' - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 0 ); - - // Mark the process as failed in case of the error. - expect( process.exitCode ).toEqual( 1 ); - } ); - - it( 'should create a POT file entry for every defined package', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'bar_id': 'bar_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-bar/src/bar.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'bar', id: 'bar_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js', 'packages/ckeditor5-bar/src/bar.js' ], - packagePaths: [ 'packages/ckeditor5-foo', 'packages/ckeditor5-bar' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-bar/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 3, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 4 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-bar/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 3, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 4, 'packages/ckeditor5-bar/src/bar.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( findMessages ) ).toHaveBeenNthCalledWith( - 1, - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - expect( vi.mocked( findMessages ) ).toHaveBeenNthCalledWith( - 2, - 'packages/ckeditor5-bar/src/bar.js_content', - 'packages/ckeditor5-bar/src/bar.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 1, - '/cwd/build/.transifex/ckeditor5-foo/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgstr "foo"', - '' - ].join( '\n' ) - ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 2, - '/cwd/build/.transifex/ckeditor5-bar/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "bar_context"', - 'msgid "bar_id"', - 'msgstr "bar"', - '' - ].join( '\n' ) - ); - } ); - - it( 'should create one POT file entry from multiple files in the same package', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context', 'bar_id': 'bar_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/bar.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'bar', id: 'bar_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js', 'packages/ckeditor5-foo/src/bar.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 3, 'packages/ckeditor5-foo/src/bar.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( findMessages ) ).toHaveBeenNthCalledWith( - 1, - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - expect( vi.mocked( findMessages ) ).toHaveBeenNthCalledWith( - 2, - 'packages/ckeditor5-foo/src/bar.js_content', - 'packages/ckeditor5-foo/src/bar.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - '/cwd/build/.transifex/ckeditor5-foo/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgstr "foo"', - '', - 'msgctxt "bar_context"', - 'msgid "bar_id"', - 'msgstr "bar"', - '' - ].join( '\n' ) - ); - } ); - - it( 'should create a POT entry filled with plural forms for message that contains has defined plural forms', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id', plural: 'foo_plural' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - '/cwd/build/.transifex/ckeditor5-foo/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgid_plural "foo_plural"', - 'msgstr[0] "foo"', - 'msgstr[1] "foo_plural"', - '' - ].join( '\n' ) - ); - } ); - - it( 'should load the core context file once and use its contexts', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo', 'packages/ckeditor5-core' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-core/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 0 ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - '/cwd/build/.transifex/ckeditor5-core/en.pot', - [ - `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, - '', - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgstr "foo"', - '' - ].join( '\n' ) - ); - } ); - - it( 'should not create a POT file for the context file if that was not added to the list of packages', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-core/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 0 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 0 ); - } ); - - it( 'should log an error if the file contains a message that cannot be parsed', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage, onErrorFound ) => { - const errors = [ - 'parse_error' - ]; - - errors.forEach( error => onErrorFound( error ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 1 ); - expect( loggerMocks.error ).toHaveBeenCalledWith( 'parse_error' ); - - // Mark the process as failed in case of the error. - expect( process.exitCode ).toEqual( 1 ); - } ); - - it( 'should log an error if two context files contain contexts the same id', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context1' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context2' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 3, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 1 ); - expect( loggerMocks.error ).toHaveBeenCalledWith( - 'Context is duplicated for the id: \'foo_id\' in ' + - 'packages/ckeditor5-core/lang/contexts.json and packages/ckeditor5-foo/lang/contexts.json.' - ); - - // Mark the process as failed in case of the error. - expect( process.exitCode ).toEqual( 1 ); - } ); - - it( 'should log an error if a context is unused', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context', 'bar_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 1 ); - expect( loggerMocks.error ).toHaveBeenCalledWith( - 'Unused context: \'bar_id\' in ckeditor5-foo/lang/contexts.json' - ); - - // Mark the process as failed in case of the error. - expect( process.exitCode ).to.equal( 1 ); - } ); - - it( 'should fail with an error describing missing properties if the required were not passed to the function', () => { - vi.mocked( verifyProperties ).mockImplementationOnce( ( options, requiredProperties ) => { - throw new Error( `The specified object misses the following properties: ${ requiredProperties.join( ', ' ) }.` ); - } ); - - try { - createPotFiles( {} ); - - throw new Error( 'Expected to throw.' ); - } catch ( err ) { - expect( err.message ).toEqual( - 'The specified object misses the following properties: sourceFiles, packagePaths, corePackagePath, translationsDirectory.' - ); - } - } ); - - it( 'should not log an error if a context from the core package is unused when ignoreUnusedCorePackageContexts=true', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context', 'bar_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'custom_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks, - ignoreUnusedCorePackageContexts: true - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 3, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( loggerMocks.error ).toHaveBeenCalledTimes( 1 ); - expect( loggerMocks.error ).toHaveBeenCalledWith( - 'Unused context: \'bar_id\' in ckeditor5-foo/lang/contexts.json' - ); - - // Mark the process as failed in case of the error. - expect( process.exitCode ).toEqual( 1 ); - } ); - - it( 'should not add the license header in the created a POT file entry when skipLicenseHeader=true', () => { - vi.mocked( fs.existsSync ).mockReturnValueOnce( true ); - vi.mocked( fs.existsSync ).mockReturnValueOnce( false ); - - vi.mocked( fs.readFileSync ).mockReturnValueOnce( JSON.stringify( { 'foo_id': 'foo_context' } ) ); - vi.mocked( fs.readFileSync ).mockReturnValueOnce( 'packages/ckeditor5-foo/src/foo.js_content' ); - - vi.mocked( findMessages ).mockImplementationOnce( ( fileContent, filePath, onFoundMessage ) => { - const messages = [ - { string: 'foo', id: 'foo_id' } - ]; - - messages.forEach( message => onFoundMessage( message ) ); - } ); - - createPotFiles( { - sourceFiles: [ 'packages/ckeditor5-foo/src/foo.js' ], - packagePaths: [ 'packages/ckeditor5-foo' ], - corePackagePath: 'packages/ckeditor5-core', - translationsDirectory: '/cwd/build/.transifex', - logger: loggerMocks, - skipLicenseHeader: true - } ); - - expect( vi.mocked( fs.existsSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json' - ); - expect( vi.mocked( fs.existsSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-core/lang/contexts.json' - ); - - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 1, 'packages/ckeditor5-foo/lang/contexts.json', 'utf-8' - ); - expect( vi.mocked( fs.readFileSync ) ).toHaveBeenNthCalledWith( - 2, 'packages/ckeditor5-foo/src/foo.js', 'utf-8' - ); - - expect( vi.mocked( findMessages ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( findMessages ) ).toHaveBeenCalledWith( - 'packages/ckeditor5-foo/src/foo.js_content', - 'packages/ckeditor5-foo/src/foo.js', - expect.any( Function ), - expect.any( Function ) - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - '/cwd/build/.transifex/ckeditor5-foo/en.pot', - [ - 'msgctxt "foo_context"', - 'msgid "foo_id"', - 'msgstr "foo"', - '' - ].join( '\n' ) - ); - } ); -} ); diff --git a/packages/ckeditor5-dev-transifex/tests/download.js b/packages/ckeditor5-dev-transifex/tests/download.js deleted file mode 100644 index 547950723..000000000 --- a/packages/ckeditor5-dev-transifex/tests/download.js +++ /dev/null @@ -1,742 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import path from 'path'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import download from '../lib/download.js'; - -import { cleanPoFileContent, createDictionaryFromPoFileContent } from '@ckeditor/ckeditor5-dev-translations'; -import { tools } from '@ckeditor/ckeditor5-dev-utils'; -import { verifyProperties, createLogger } from '../lib/utils.js'; -import fs from 'fs-extra'; -import transifexService from '../lib/transifexservice.js'; - -vi.mock( '../lib/transifexservice.js' ); -vi.mock( '../lib/utils.js' ); -vi.mock( '@ckeditor/ckeditor5-dev-translations' ); -vi.mock( '@ckeditor/ckeditor5-dev-utils' ); -vi.mock( 'fs-extra' ); - -vi.mock( 'chalk', () => ( { - default: { - underline: vi.fn( string => string ), - gray: vi.fn( string => string ) - } -} ) ); - -vi.mock( '../lib/data/index.js', () => { - return { - languageCodeMap: { - ne_NP: 'ne' - } - }; -} ); - -describe( 'dev-transifex/download()', () => { - let mocks; - let loggerProgressMock, loggerInfoMock, loggerWarningMock, loggerErrorMock, loggerLogMock; - let spinnerStartMock, spinnerFinishMock; - - beforeEach( () => { - loggerProgressMock = vi.fn(); - loggerInfoMock = vi.fn(); - loggerWarningMock = vi.fn(); - loggerErrorMock = vi.fn(); - loggerErrorMock = vi.fn(); - - vi.mocked( createLogger ).mockImplementation( () => { - return { - progress: loggerProgressMock, - info: loggerInfoMock, - warning: loggerWarningMock, - error: loggerErrorMock, - _log: loggerLogMock - }; - } ); - - spinnerStartMock = vi.fn(); - spinnerFinishMock = vi.fn(); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: spinnerStartMock, - finish: spinnerFinishMock - } ); - - vi.mocked( fs.existsSync ).mockImplementation( () => Boolean( mocks.oldFailedDownloads ) ); - vi.mocked( fs.readJsonSync ).mockImplementation( () => mocks.oldFailedDownloads ); - - vi.mocked( createDictionaryFromPoFileContent ).mockImplementation( fileContent => mocks.fileContents[ fileContent ] ); - vi.mocked( cleanPoFileContent ).mockImplementation( fileContent => fileContent ); - - vi.mocked( transifexService.getResourceName ).mockImplementation( resource => resource.attributes.slug ); - vi.mocked( transifexService.getLanguageCode ).mockImplementation( language => language.attributes.code ); - vi.mocked( transifexService.getProjectData ).mockImplementation( ( organizationName, projectName, localizablePackageNames ) => { - const projectData = { - resources: mocks.resources.filter( resource => localizablePackageNames.includes( resource.attributes.slug ) ), - languages: mocks.languages - }; - - return Promise.resolve( projectData ); - } ); - vi.mocked( transifexService.getTranslations ).mockImplementation( ( resource, languages ) => { - const translationData = { - translations: new Map( - languages.map( language => [ - language.attributes.code, - mocks.translations[ resource.attributes.slug ][ language.attributes.code ] - ] ) - ), - failedDownloads: mocks.newFailedDownloads ? - mocks.newFailedDownloads.filter( item => { - const isResourceNameMatched = item.resourceName === resource.attributes.slug; - const isLanguageCodeMatched = languages.find( language => item.languageCode === language.attributes.code ); - - return isResourceNameMatched && isLanguageCodeMatched; - } ) : - [] - }; - - return Promise.resolve( translationData ); - } ); - } ); - - it( 'should fail if properties verification failed', () => { - const error = new Error( 'The specified object misses the following properties: packages.' ); - const config = { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken' - }; - - vi.mocked( verifyProperties ).mockImplementation( () => { - throw new Error( error ); - } ); - - return download( config ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - caughtError => { - expect( caughtError.message.endsWith( error.message ) ).toEqual( true ); - - expect( vi.mocked( verifyProperties ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( verifyProperties ) ).toHaveBeenCalledWith( - config, [ 'organizationName', 'projectName', 'token', 'packages', 'cwd' ] - ); - } - ); - } ); - - it( 'should remove translations before downloading', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } } - ], - languages: [ - { attributes: { code: 'pl' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' } - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ] - ] ) - } ); - - expect( vi.mocked( fs.removeSync ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.removeSync ) ).toHaveBeenNthCalledWith( - 1, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations' ) - ); - expect( vi.mocked( fs.removeSync ) ).toHaveBeenNthCalledWith( - 2, - path.normalize( '/workspace/.transifex-failed-downloads.json' ) - ); - - const removeSyncMockFirstCallOrder = vi.mocked( fs.removeSync ).mock.invocationCallOrder[ 0 ]; - const removeSyncMockSecondCallOrder = vi.mocked( fs.removeSync ).mock.invocationCallOrder[ 1 ]; - const outputFileSyncMockFirstCallOrder = vi.mocked( fs.outputFileSync ).mock.invocationCallOrder[ 0 ]; - - expect( removeSyncMockFirstCallOrder < outputFileSyncMockFirstCallOrder ).toEqual( true ); - expect( outputFileSyncMockFirstCallOrder < removeSyncMockSecondCallOrder ).toEqual( true ); - } ); - - it( 'should download translations for non-empty resources', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': {} - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 3 ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 1, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/pl.po' ), - 'ckeditor5-core-pl-content' - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 2, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/de.po' ), - 'ckeditor5-core-de-content' - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 3, - path.normalize( '/workspace/bar/ckeditor5-ui/lang/translations/pl.po' ), - 'ckeditor5-ui-pl-content' - ); - - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( - 1, 'Fetching project information...' - ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( - 2, 'Downloading all translations...' - ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( - 3, 'Saved all translations.' - ); - - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenNthCalledWith( - 1, ' Saved 2 "*.po" file(s).' - ); - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenNthCalledWith( - 2, ' Saved 1 "*.po" file(s).' - ); - } ); - - it( 'should download translations for non-empty resources only for specified packages', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': {} - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - path.normalize( '/workspace/bar/ckeditor5-ui/lang/translations/pl.po' ), - 'ckeditor5-ui-pl-content' - ); - } ); - - it( 'should create spinner for each processed package', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': {} - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( spinnerStartMock ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( spinnerFinishMock ) ).toHaveBeenCalledTimes( 2 ); - - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenNthCalledWith( - 1, - 'Processing "ckeditor5-core"...', - { indentLevel: 1, emoji: '👉' } - ); - - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenNthCalledWith( - 2, - 'Processing "ckeditor5-ui"...', - { indentLevel: 1, emoji: '👉' } - ); - } ); - - it( 'should skip creating a translation file if there are no resources', async () => { - mocks = { - resources: [], - languages: [], - translations: {}, - fileContents: {} - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-non-existing', 'foo/ckeditor5-non-existing' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 0 ); - } ); - - it( 'should save failed downloads', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': { cancel: 'cancel_de' } - }, - newFailedDownloads: [ - { resourceName: 'ckeditor5-ui', languageCode: 'de', errorMessage: 'An example error.' } - ] - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( fs.writeJSONSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.writeJSONSync ) ).toHaveBeenCalledWith( - path.normalize( '/workspace/.transifex-failed-downloads.json' ), - [ { resourceName: 'ckeditor5-ui', languages: [ { code: 'de', errorMessage: 'An example error.' } ] } ], - { spaces: 2 } - ); - - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenNthCalledWith( 1, ' Saved 2 "*.po" file(s).' ); - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenNthCalledWith( 2, ' Saved 2 "*.po" file(s). 1 requests failed.' ); - - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 1, - 'Not all translations were downloaded due to errors in Transifex API.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 2, - `Review the "${ path.normalize( '/workspace/.transifex-failed-downloads.json' ) }" file for more details.` - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 3, - 'Re-running the script will process only packages specified in the file.' - ); - - expect( vi.mocked( spinnerFinishMock ) ).toHaveBeenCalledTimes( 2 ); - // First call: OK. Second call: error. - expect( vi.mocked( spinnerFinishMock ) ).toHaveBeenNthCalledWith( 1 ); - expect( vi.mocked( spinnerFinishMock ) ).toHaveBeenNthCalledWith( 2, { emoji: '❌' } ); - } ); - - it( 'should use the language code from the "languagecodemap.json" if it exists, or the default language code otherwise', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'en_AU' } }, - { attributes: { code: 'ne_NP' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - en_AU: 'ckeditor5-core-en_AU-content', - ne_NP: 'ckeditor5-core-ne_NP-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-en_AU-content': { save: 'save_en_AU' }, - 'ckeditor5-core-ne_NP-content': { save: 'save_ne_NP' } - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 3 ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 1, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/pl.po' ), - 'ckeditor5-core-pl-content' - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 2, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/en_AU.po' ), - 'ckeditor5-core-en_AU-content' - ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenNthCalledWith( - 3, - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/ne.po' ), - 'ckeditor5-core-ne_NP-content' - ); - } ); - - it( 'should fail with an error when the transifex service responses with an error', async () => { - const error = new Error( 'An example error.' ); - - vi.mocked( transifexService.getProjectData ).mockRejectedValue( error ); - - try { - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - token: 'secretToken', - cwd: '/workspace', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - } catch ( err ) { - expect( err ).to.equal( error ); - } - - expect( vi.mocked( transifexService.getProjectData ) ).toHaveBeenCalled(); - } ); - - it( 'should pass the "simplifyLicenseHeader" flag to the "cleanPoFileContent()" function when set to `true`', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } } - ], - languages: [ - { attributes: { code: 'pl' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' } - } - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-non-existing', 'foo/ckeditor5-non-existing' ] - ] ), - simplifyLicenseHeader: true - } ); - - expect( vi.mocked( cleanPoFileContent ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( cleanPoFileContent ) ).toHaveBeenCalledWith( - 'ckeditor5-core-pl-content', - { - simplifyLicenseHeader: true - } - ); - } ); - - describe( 'recovery mode with existing ".transifex-failed-downloads.json" file', () => { - it( 'should not remove any translations beforehand', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } } - ], - languages: [ - { attributes: { code: 'pl' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' } - }, - oldFailedDownloads: [ - { resourceName: 'ckeditor5-core', languages: [ { code: 'pl' } ] } - ] - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.removeSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.removeSync ) ).toHaveBeenCalledWith( - path.normalize( '/workspace/.transifex-failed-downloads.json' ) - ); - - const outputFileSyncMockFirstCallOrder = vi.mocked( fs.outputFileSync ).mock.invocationCallOrder[ 0 ]; - const removeSyncMockFirstCallOrder = vi.mocked( fs.removeSync ).mock.invocationCallOrder[ 0 ]; - - expect( outputFileSyncMockFirstCallOrder < removeSyncMockFirstCallOrder ).toEqual( true ); - } ); - - it( 'should download translations for existing resources but only for previously failed ones', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': { cancel: 'cancel_de' } - }, - oldFailedDownloads: [ - { resourceName: 'ckeditor5-core', languages: [ { code: 'pl' } ] }, - { resourceName: 'ckeditor5-non-existing', languages: [ { code: 'de' } ] } - ] - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.outputFileSync ) ).toHaveBeenCalledWith( - path.normalize( '/workspace/foo/ckeditor5-core/lang/translations/pl.po' ), - 'ckeditor5-core-pl-content' - ); - - expect( loggerWarningMock ).toHaveBeenCalledTimes( 2 ); - expect( loggerWarningMock ).toHaveBeenNthCalledWith( - 1, - 'Found the file containing a list of packages that failed during the last script execution.' - ); - expect( loggerWarningMock ).toHaveBeenNthCalledWith( - 2, - 'The script will process only packages listed in the file instead of all passed as "config.packages".' - ); - - expect( loggerProgressMock ).toHaveBeenCalledTimes( 3 ); - expect( loggerProgressMock ).toHaveBeenNthCalledWith( 1, 'Fetching project information...' ); - expect( loggerProgressMock ).toHaveBeenNthCalledWith( 2, 'Downloading only translations that failed previously...' ); - expect( loggerProgressMock ).toHaveBeenNthCalledWith( 3, 'Saved all translations.' ); - } ); - - it( 'should update ".transifex-failed-downloads.json" file if there are still some failed downloads', async () => { - mocks = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'ckeditor5-core': { - pl: 'ckeditor5-core-pl-content', - de: 'ckeditor5-core-de-content' - }, - 'ckeditor5-ui': { - pl: 'ckeditor5-ui-pl-content', - de: 'ckeditor5-ui-de-content' - } - }, - fileContents: { - 'ckeditor5-core-pl-content': { save: 'save_pl' }, - 'ckeditor5-core-de-content': { save: 'save_de' }, - 'ckeditor5-ui-pl-content': { cancel: 'cancel_pl' }, - 'ckeditor5-ui-de-content': {} - }, - oldFailedDownloads: [ - { resourceName: 'ckeditor5-core', languages: [ { code: 'pl' }, { code: 'de' } ] }, - { resourceName: 'ckeditor5-non-existing', languages: [ { code: 'de' } ] } - ], - newFailedDownloads: [ - { resourceName: 'ckeditor5-core', languageCode: 'de', errorMessage: 'An example error.' } - ] - }; - - await download( { - organizationName: 'ckeditor-organization', - projectName: 'ckeditor5-project', - cwd: '/workspace', - token: 'secretToken', - packages: new Map( [ - [ 'ckeditor5-core', 'foo/ckeditor5-core' ], - [ 'ckeditor5-ui', 'bar/ckeditor5-ui' ] - ] ) - } ); - - expect( vi.mocked( fs.writeJSONSync ) ).toHaveBeenCalledTimes( 1 ); - - expect( vi.mocked( fs.writeJSONSync ) ).toHaveBeenCalledWith( - path.normalize( '/workspace/.transifex-failed-downloads.json' ), - [ { resourceName: 'ckeditor5-core', languages: [ { code: 'de', errorMessage: 'An example error.' } ] } ], - { spaces: 2 } - ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-dev-transifex/tests/transifexservice.js b/packages/ckeditor5-dev-transifex/tests/transifexservice.js deleted file mode 100644 index 552ddcfad..000000000 --- a/packages/ckeditor5-dev-transifex/tests/transifexservice.js +++ /dev/null @@ -1,951 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import transifexService from '../lib/transifexservice.js'; - -const { - transifexApiMock -} = vi.hoisted( () => { - return { - transifexApiMock: {} - }; -} ); - -vi.mock( '@transifex/api', () => { - return { - transifexApi: transifexApiMock - }; -} ); - -describe( 'dev-transifex/transifex-service', () => { - let testData; - - let fetchMock; - - let createResourceMock; - let createResourceStringsAsyncUploadMock; - let dataResourceTranslationsMock; - let fetchOrganizationMock; - let fetchProjectMock; - let fetchResourceTranslationsMock; - let filterResourceTranslationsMock; - let getNextResourceTranslationsMock; - let getOrganizationsMock; - let getProjectsMock; - let getResourceStringsAsyncUploadMock; - let includeResourceTranslationsMock; - - beforeEach( () => { - fetchMock = vi.fn(); - - vi.stubGlobal( 'fetch', fetchMock ); - - createResourceMock = vi.fn(); - createResourceStringsAsyncUploadMock = vi.fn(); - fetchResourceTranslationsMock = vi.fn(); - filterResourceTranslationsMock = vi.fn(); - getNextResourceTranslationsMock = vi.fn(); - getResourceStringsAsyncUploadMock = vi.fn(); - includeResourceTranslationsMock = vi.fn(); - - getOrganizationsMock = vi.fn().mockImplementation( () => Promise.resolve( { - fetch: fetchOrganizationMock - } ) ); - - fetchOrganizationMock = vi.fn().mockImplementation( () => Promise.resolve( { - get: getProjectsMock - } ) ); - - getProjectsMock = vi.fn().mockImplementation( () => Promise.resolve( { - fetch: fetchProjectMock - } ) ); - - fetchProjectMock = vi.fn().mockImplementation( resourceType => Promise.resolve( { - async* all() { - for ( const item of testData[ resourceType ] ) { - yield item; - } - } - } ) ); - - transifexApiMock.setup = vi.fn().mockImplementation( ( { auth } ) => { - transifexApiMock.auth = vi.fn().mockReturnValue( { Authorization: `Bearer ${ auth }` } ); - } ); - - transifexApiMock.Organization = { - get: ( ...args ) => getOrganizationsMock( ...args ) - }; - - transifexApiMock.Resource = { - create: ( ...args ) => createResourceMock( ...args ) - }; - - transifexApiMock.ResourceStringsAsyncUpload = { - create: ( ...args ) => createResourceStringsAsyncUploadMock( ...args ), - get: ( ...args ) => getResourceStringsAsyncUploadMock( ...args ) - }; - - transifexApiMock.ResourceStringsAsyncDownload = resourceAsyncDownloadMockFactory(); - transifexApiMock.ResourceTranslationsAsyncDownload = resourceAsyncDownloadMockFactory(); - - transifexApiMock.ResourceTranslation = { - filter: ( ...args ) => { - filterResourceTranslationsMock( ...args ); - - return { - include: ( ...args ) => { - includeResourceTranslationsMock( ...args ); - - return { - fetch: ( ...args ) => fetchResourceTranslationsMock( ...args ), - get data() { - return dataResourceTranslationsMock; - }, - get next() { - return !!getNextResourceTranslationsMock; - }, - getNext: () => getNextResourceTranslationsMock() - }; - } - }; - } - }; - } ); - - afterEach( () => { - vi.useRealTimers(); - - // Restoring mock of Transifex API. - Object.keys( transifexApiMock ).forEach( mockedKey => delete transifexApiMock[ mockedKey ] ); - } ); - - describe( 'init()', () => { - it( 'should pass the token to the Transifex API', () => { - transifexService.init( 'secretToken' ); - - expect( transifexApiMock.auth ).toBeInstanceOf( Function ); - expect( transifexApiMock.auth() ).toEqual( { Authorization: 'Bearer secretToken' } ); - } ); - - it( 'should pass the token to the Transifex API only once', () => { - transifexService.init( 'secretToken' ); - transifexService.init( 'anotherSecretToken' ); - transifexService.init( 'evenBetterSecretToken' ); - - expect( transifexApiMock.setup ).toHaveBeenCalledTimes( 1 ); - - expect( transifexApiMock.auth ).toBeInstanceOf( Function ); - expect( transifexApiMock.auth() ).toEqual( { Authorization: 'Bearer secretToken' } ); - } ); - } ); - - describe( 'getProjectData()', () => { - it( 'should return resources and languages, with English language as the source one', async () => { - testData = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ] - }; - - const { resources, languages } = await transifexService.getProjectData( - 'ckeditor-organization', 'ckeditor5-project', [ 'ckeditor5-core', 'ckeditor5-ui' ] - ); - - expect( getOrganizationsMock ).toHaveBeenCalledTimes( 1 ); - expect( getOrganizationsMock ).toHaveBeenCalledWith( { slug: 'ckeditor-organization' } ); - - expect( fetchOrganizationMock ).toHaveBeenCalledTimes( 1 ); - expect( fetchOrganizationMock ).toHaveBeenCalledWith( 'projects' ); - - expect( getProjectsMock ).toHaveBeenCalledTimes( 1 ); - expect( getProjectsMock ).toHaveBeenCalledWith( { slug: 'ckeditor5-project' } ); - - expect( fetchProjectMock ).toHaveBeenCalledTimes( 2 ); - expect( fetchProjectMock ).toHaveBeenNthCalledWith( 1, 'resources' ); - expect( fetchProjectMock ).toHaveBeenNthCalledWith( 2, 'languages' ); - - expect( resources ).toEqual( [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ] ); - - expect( languages ).toEqual( [ - { attributes: { code: 'en' } }, - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ] ); - } ); - - it( 'should return only the available resources that were requested', async () => { - testData = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ] - }; - - const { resources, languages } = await transifexService.getProjectData( - 'ckeditor-organization', 'ckeditor5-project', [ 'ckeditor5-core', 'ckeditor5-non-existing' ] - ); - - expect( resources ).toEqual( [ - { attributes: { slug: 'ckeditor5-core' } } - ] ); - - expect( languages ).toEqual( [ - { attributes: { code: 'en' } }, - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ] ); - } ); - } ); - - describe( 'getTranslations()', () => { - beforeEach( () => { - testData = { - resources: [ - { attributes: { slug: 'ckeditor5-core' } }, - { attributes: { slug: 'ckeditor5-ui' } } - ], - languages: [ - { attributes: { code: 'en' } }, - { attributes: { code: 'pl' } }, - { attributes: { code: 'de' } } - ], - translations: { - 'https://example.com/ckeditor5-core/en': 'ckeditor5-core-en-content', - 'https://example.com/ckeditor5-core/pl': 'ckeditor5-core-pl-content', - 'https://example.com/ckeditor5-core/de': 'ckeditor5-core-de-content', - 'https://example.com/ckeditor5-ui/en': 'ckeditor5-ui-en-content', - 'https://example.com/ckeditor5-ui/pl': 'ckeditor5-ui-pl-content', - 'https://example.com/ckeditor5-ui/de': 'ckeditor5-ui-de-content' - } - }; - - transifexService.init( 'secretToken' ); - } ); - - it( 'should return requested translations if no retries are needed', async () => { - vi.mocked( fetchMock ).mockImplementation( url => Promise.resolve( { - ok: true, - redirected: true, - text: () => Promise.resolve( testData.translations[ url ] ) - } ) ); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const { translations, failedDownloads } = await transifexService.getTranslations( resource, languages ); - - const attributes = { - callback_url: null, - content_encoding: 'text', - file_type: 'default', - pseudo: false - }; - - expect( transifexApiMock.ResourceStringsAsyncDownload.create ).toHaveBeenCalledTimes( 1 ); - - expect( transifexApiMock.ResourceStringsAsyncDownload.create ).toHaveBeenCalledWith( { - attributes, - relationships: { - resource - }, - type: 'resource_strings_async_downloads' - } ); - - expect( transifexApiMock.ResourceTranslationsAsyncDownload.create ).toHaveBeenCalledTimes( 2 ); - - expect( transifexApiMock.ResourceTranslationsAsyncDownload.create ).toHaveBeenNthCalledWith( 1, { - attributes, - relationships: { - resource, - language: languages[ 1 ] - }, - type: 'resource_translations_async_downloads' - } ); - - expect( transifexApiMock.ResourceTranslationsAsyncDownload.create ).toHaveBeenNthCalledWith( 2, { - attributes, - relationships: { - resource, - language: languages[ 2 ] - }, - type: 'resource_translations_async_downloads' - } ); - - expect( fetchMock ).toHaveBeenCalledTimes( 3 ); - - expect( fetchMock ).toHaveBeenNthCalledWith( 1, 'https://example.com/ckeditor5-core/en', { - method: 'GET', - headers: { - Authorization: 'Bearer secretToken' - } - } ); - - expect( fetchMock ).toHaveBeenNthCalledWith( 2, 'https://example.com/ckeditor5-core/pl', { - method: 'GET', - headers: { - Authorization: 'Bearer secretToken' - } - } ); - - expect( fetchMock ).toHaveBeenNthCalledWith( 3, 'https://example.com/ckeditor5-core/de', { - method: 'GET', - headers: { - Authorization: 'Bearer secretToken' - } - } ); - - expect( [ ...translations.entries() ] ).toEqual( [ - [ 'en', 'ckeditor5-core-en-content' ], - [ 'pl', 'ckeditor5-core-pl-content' ], - [ 'de', 'ckeditor5-core-de-content' ] - ] ); - - expect( failedDownloads ).toEqual( [] ); - } ); - - it( 'should return requested translations after multiple different download retries', async () => { - vi.useFakeTimers(); - - const languageCallsBeforeResolving = { - en: 9, - pl: 4, - de: 7 - }; - - fetchMock.mockImplementation( url => { - const language = url.split( '/' ).pop(); - - if ( languageCallsBeforeResolving[ language ] > 0 ) { - languageCallsBeforeResolving[ language ]--; - - return Promise.resolve( { - ok: true, - redirected: false - } ); - } - - return Promise.resolve( { - ok: true, - redirected: true, - text: () => Promise.resolve( testData.translations[ url ] ) - } ); - } ); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const translationsPromise = transifexService.getTranslations( resource, languages ); - - await vi.advanceTimersByTimeAsync( 30000 ); - - const { translations, failedDownloads } = await translationsPromise; - - expect( fetchMock ).toHaveBeenCalledTimes( 23 ); - - expect( [ ...translations.entries() ] ).toEqual( [ - [ 'en', 'ckeditor5-core-en-content' ], - [ 'pl', 'ckeditor5-core-pl-content' ], - [ 'de', 'ckeditor5-core-de-content' ] - ] ); - - expect( failedDownloads ).toEqual( [] ); - } ); - - it( 'should return failed requests if all file downloads failed', async () => { - vi.mocked( fetchMock ).mockResolvedValue( { - ok: false, - status: 500, - statusText: 'Internal Server Error' - } ); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const { translations, failedDownloads } = await transifexService.getTranslations( resource, languages ); - - const expectedFailedDownloads = [ 'en', 'pl', 'de' ].map( languageCode => ( { - resourceName: 'ckeditor5-core', - languageCode, - errorMessage: 'Failed to download the translation file. Received response: 500 Internal Server Error' - } ) ); - - expect( failedDownloads ).toEqual( expectedFailedDownloads ); - expect( [ ...translations.entries() ] ).toEqual( [] ); - } ); - - it( 'should return failed requests if the retry limit has been reached for all requests', async () => { - vi.useFakeTimers(); - - vi.mocked( fetchMock ).mockResolvedValue( { - ok: true, - redirected: false - } ); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const translationsPromise = transifexService.getTranslations( resource, languages ); - - await vi.advanceTimersByTimeAsync( 30000 ); - - const { translations, failedDownloads } = await translationsPromise; - - const expectedFailedDownloads = [ 'en', 'pl', 'de' ].map( languageCode => ( { - resourceName: 'ckeditor5-core', - languageCode, - errorMessage: 'Failed to download the translation file. ' + - 'Requested file is not ready yet, but the limit of file download attempts has been reached.' - } ) ); - - expect( failedDownloads ).toEqual( expectedFailedDownloads ); - expect( [ ...translations.entries() ] ).toEqual( [] ); - } ); - - it( 'should return failed requests if it is not possible to create all initial download requests', async () => { - vi.useFakeTimers(); - - transifexApiMock.ResourceStringsAsyncDownload.create.mockRejectedValue(); - transifexApiMock.ResourceTranslationsAsyncDownload.create.mockRejectedValue(); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const translationsPromise = transifexService.getTranslations( resource, languages ); - - await vi.advanceTimersByTimeAsync( 30000 ); - - const { translations, failedDownloads } = await translationsPromise; - - const expectedFailedDownloads = [ 'en', 'pl', 'de' ].map( languageCode => ( { - resourceName: 'ckeditor5-core', - languageCode, - errorMessage: 'Failed to create download request.' - } ) ); - - expect( failedDownloads ).toEqual( expectedFailedDownloads ); - expect( [ ...translations.entries() ] ).toEqual( [] ); - } ); - - it( 'should return requested translations and failed downloads in multiple different download scenarios', async () => { - vi.useFakeTimers(); - - transifexApiMock.ResourceStringsAsyncDownload.create.mockRejectedValue(); - - transifexApiMock.ResourceTranslationsAsyncDownload.create - .mockRejectedValueOnce() - .mockRejectedValueOnce() - .mockRejectedValueOnce(); - - const languageCallsBeforeResolving = { - pl: 4, - de: 8 - }; - - fetchMock.mockImplementation( url => { - const language = url.split( '/' ).pop(); - - if ( languageCallsBeforeResolving[ language ] > 0 ) { - languageCallsBeforeResolving[ language ]--; - - return Promise.resolve( { - ok: true, - redirected: false - } ); - } - - if ( language === 'pl' ) { - return Promise.resolve( { - ok: true, - redirected: true, - text: () => Promise.resolve( testData.translations[ url ] ) - } ); - } - - if ( language === 'de' ) { - return Promise.resolve( { - ok: false, - status: 500, - statusText: 'Internal Server Error' - } ); - } - } ); - - const resource = testData.resources[ 0 ]; - const languages = [ ...testData.languages ]; - const translationsPromise = transifexService.getTranslations( resource, languages ); - - await vi.advanceTimersByTimeAsync( 60000 ); - - const { translations, failedDownloads } = await translationsPromise; - - expect( fetchMock ).toHaveBeenCalledTimes( 14 ); - - expect( [ ...translations.entries() ] ).toEqual( [ - [ 'pl', 'ckeditor5-core-pl-content' ] - ] ); - - expect( failedDownloads ).toEqual( [ - { - resourceName: 'ckeditor5-core', - languageCode: 'en', - errorMessage: 'Failed to create download request.' - }, - { - resourceName: 'ckeditor5-core', - languageCode: 'de', - errorMessage: 'Failed to download the translation file. Received response: 500 Internal Server Error' - } - ] ); - } ); - } ); - - describe( 'getResourceTranslations', () => { - it( 'should return all found translations', () => { - getNextResourceTranslationsMock = null; - fetchResourceTranslationsMock.mockImplementation( () => { - dataResourceTranslationsMock = [ - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:680k83DmCPu9AkGVwDvVQqCvsJkg93AC:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:MbFEbBcsOk43LryccpBHPyeMYBW6G5FV:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:tQ8xmNQ706zjL3hiqEsttqUoneZJtV4Q:l:en' } - ]; - - return Promise.resolve(); - } ); - - return transifexService.getResourceTranslations( 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', 'l:en' ) - .then( result => { - expect( result ).toEqual( [ - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:680k83DmCPu9AkGVwDvVQqCvsJkg93AC:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:MbFEbBcsOk43LryccpBHPyeMYBW6G5FV:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:tQ8xmNQ706zjL3hiqEsttqUoneZJtV4Q:l:en' } - ] ); - - expect( filterResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - expect( filterResourceTranslationsMock ).toHaveBeenCalledWith( { - resource: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', - language: 'l:en' - } ); - - expect( includeResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - expect( includeResourceTranslationsMock ).toHaveBeenCalledWith( 'resource_string' ); - - expect( fetchResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - } ); - } ); - - it( 'should return all found translations if results are paginated', () => { - const availableTranslations = [ - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:680k83DmCPu9AkGVwDvVQqCvsJkg93AC:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:MbFEbBcsOk43LryccpBHPyeMYBW6G5FV:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:tQ8xmNQ706zjL3hiqEsttqUoneZJtV4Q:l:en' } - ]; - - getNextResourceTranslationsMock.mockImplementation( () => { - dataResourceTranslationsMock = [ availableTranslations.shift() ]; - - return Promise.resolve( { - data: dataResourceTranslationsMock, - next: availableTranslations.length > 0, - getNext: getNextResourceTranslationsMock - } ); - } ); - - fetchResourceTranslationsMock.mockImplementation( () => { - dataResourceTranslationsMock = [ availableTranslations.shift() ]; - - return Promise.resolve(); - } ); - - return transifexService.getResourceTranslations( 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', 'l:en' ) - .then( result => { - expect( result ).toEqual( [ - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:680k83DmCPu9AkGVwDvVQqCvsJkg93AC:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:MbFEbBcsOk43LryccpBHPyeMYBW6G5FV:l:en' }, - { id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo:s:tQ8xmNQ706zjL3hiqEsttqUoneZJtV4Q:l:en' } - ] ); - - expect( filterResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - expect( filterResourceTranslationsMock ).toHaveBeenCalledWith( { - resource: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', - language: 'l:en' - } ); - - expect( includeResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - expect( includeResourceTranslationsMock ).toHaveBeenCalledWith( 'resource_string' ); - - expect( fetchResourceTranslationsMock ).toHaveBeenCalledTimes( 1 ); - } ); - } ); - - it( 'should reject a promise if Transifex API rejected', async () => { - const apiError = new Error( 'JsonApiError: 418, I\'m a teapot' ); - - fetchResourceTranslationsMock.mockRejectedValue( apiError ); - - return transifexService.getResourceTranslations( 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', 'l:en' ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - error => { - expect( apiError ).toEqual( error ); - } - ); - } ); - } ); - - describe( 'getResourceName()', () => { - it( 'should extract the resource name from the resource instance', () => { - const resource = { attributes: { slug: 'ckeditor5-core' } }; - - expect( transifexService.getResourceName( resource ) ).toEqual( 'ckeditor5-core' ); - } ); - } ); - - describe( 'getLanguageCode()', () => { - it( 'should extract the language code from the language instance', () => { - const language = { attributes: { code: 'pl' } }; - - expect( transifexService.getLanguageCode( language ) ).toEqual( 'pl' ); - } ); - } ); - - describe( 'isSourceLanguage()', () => { - it( 'should return false if the language instance is not the source language', () => { - const language = { attributes: { code: 'pl' } }; - - expect( transifexService.isSourceLanguage( language ) ).toEqual( false ); - } ); - - it( 'should return true if the language instance is the source language', () => { - const language = { attributes: { code: 'en' } }; - - expect( transifexService.isSourceLanguage( language ) ).toEqual( true ); - } ); - } ); - - describe( 'createResource', () => { - it( 'should create a new resource and return its attributes', () => { - const organizationName = 'ckeditor'; - const projectName = 'ckeditor5'; - const resourceName = 'ckeditor5-foo'; - - const apiResponse = { - data: { - attributes: {}, - id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', - links: {}, - relationships: { - i18n_format: { - data: { - id: 'PO', - type: 'i18n_formats' - } - }, - project: { - data: { - id: 'o:ckeditor:p:ckeditor5', - type: 'projects' - } - } - }, - type: 'resources' - } - }; - - createResourceMock.mockResolvedValue( apiResponse ); - - return transifexService.createResource( { organizationName, projectName, resourceName } ) - .then( response => { - expect( createResourceMock ).toHaveBeenCalledTimes( 1 ); - expect( createResourceMock ).toHaveBeenCalledWith( { - name: 'ckeditor5-foo', - relationships: { - i18n_format: { - data: { - id: 'PO', - type: 'i18n_formats' - } - }, - project: { - data: { - id: 'o:ckeditor:p:ckeditor5', - type: 'projects' - } - } - }, - slug: 'ckeditor5-foo' - } ); - - expect( response ).toEqual( apiResponse ); - } ); - } ); - - it( 'should reject a promise if Transifex API rejected', () => { - const organizationName = 'ckeditor'; - const projectName = 'ckeditor5'; - const resourceName = 'ckeditor5-foo'; - - const apiError = new Error( 'JsonApiError: 418, I\'m a teapot' ); - - createResourceMock.mockRejectedValue( apiError ); - - return transifexService.createResource( { organizationName, projectName, resourceName } ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - err => { - expect( apiError ).toEqual( err ); - } - ); - } ); - } ); - - describe( 'createSourceFile', () => { - it( 'should create a new resource and return its attributes', () => { - const organizationName = 'ckeditor'; - const projectName = 'ckeditor5'; - const resourceName = 'ckeditor5-foo'; - const content = '# ckeditor5-foo'; - - const apiResponse = { - id: '4abfc726-6a27-4c33-9d99-e5254c8df748', - type: 'resource_strings_async_uploads' - }; - - createResourceStringsAsyncUploadMock.mockResolvedValue( apiResponse ); - - return transifexService.createSourceFile( { organizationName, projectName, resourceName, content } ) - .then( response => { - expect( createResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 1 ); - expect( createResourceStringsAsyncUploadMock ).toHaveBeenCalledWith( { - attributes: { - content: '# ckeditor5-foo', - content_encoding: 'text' - }, - relationships: { - resource: { - data: { - id: 'o:ckeditor:p:ckeditor5:r:ckeditor5-foo', - type: 'resources' - } - } - }, - type: 'resource_strings_async_uploads' - } ); - - expect( response ).toEqual( apiResponse.id ); - } ); - } ); - - it( 'should reject a promise if Transifex API rejected', () => { - const organizationName = 'ckeditor'; - const projectName = 'ckeditor5'; - const resourceName = 'ckeditor5-foo'; - const content = '# ckeditor5-foo'; - - const apiError = new Error( 'JsonApiError: 418, I\'m a teapot' ); - - createResourceStringsAsyncUploadMock.mockRejectedValue( apiError ); - - return transifexService.createSourceFile( { organizationName, projectName, resourceName, content } ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - err => { - expect( apiError ).toEqual( err ); - } - ); - } ); - } ); - - describe( 'getResourceUploadDetails', () => { - beforeEach( () => { - vi.useFakeTimers(); - } ); - - it( 'should return a promise with resolved upload details (Transifex processed the upload)', async () => { - const apiResponse = { - id: '4abfc726-6a27-4c33-9d99-e5254c8df748', - attributes: { - status: 'succeeded' - }, - type: 'resource_strings_async_uploads' - }; - - getResourceStringsAsyncUploadMock.mockResolvedValue( apiResponse ); - - const promise = transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - expect( promise ).toBeInstanceOf( Promise ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 1 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledWith( '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - const result = await promise; - - expect( result ).toEqual( apiResponse ); - } ); - - it( 'should return a promise that resolves after 3000ms (Transifex processed the upload 1s, status=pending)', async () => { - const apiResponse = { - id: '4abfc726-6a27-4c33-9d99-e5254c8df748', - attributes: { - status: 'succeeded' - }, - type: 'resource_strings_async_uploads' - }; - - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( { - attributes: { status: 'pending' } - } ); - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( apiResponse ); - - const promise = transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - expect( promise ).toBeInstanceOf( Promise ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 1 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenNthCalledWith( 1, '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - await vi.advanceTimersByTimeAsync( 3000 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 2 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenNthCalledWith( 2, '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - expect( await promise ).toEqual( apiResponse ); - } ); - - it( 'should return a promise that resolves after 3000ms (Transifex processed the upload 1s, status=processing)', async () => { - const apiResponse = { - id: '4abfc726-6a27-4c33-9d99-e5254c8df748', - attributes: { - status: 'succeeded' - }, - type: 'resource_strings_async_uploads' - }; - - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( { - attributes: { status: 'processing' } - } ); - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( apiResponse ); - - const promise = transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - expect( promise ).toBeInstanceOf( Promise ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 1 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenNthCalledWith( 1, '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - await vi.advanceTimersByTimeAsync( 3000 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenCalledTimes( 2 ); - expect( getResourceStringsAsyncUploadMock ).toHaveBeenNthCalledWith( 2, '4abfc726-6a27-4c33-9d99-e5254c8df748' ); - - expect( await promise ).toEqual( apiResponse ); - } ); - - it( 'should return a promise that rejects if Transifex returned an error (no-delay)', async () => { - const apiResponse = new Error( 'JsonApiError' ); - - getResourceStringsAsyncUploadMock.mockRejectedValue( apiResponse ); - - return transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - err => { - expect( err ).toEqual( apiResponse ); - } - ); - } ); - - it( 'should return a promise that rejects if Transifex returned an error (delay)', async () => { - const apiResponse = new Error( 'JsonApiError' ); - - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( { - attributes: { status: 'processing' } - } ); - getResourceStringsAsyncUploadMock.mockRejectedValueOnce( apiResponse ); - - const promise = transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - err => { - expect( err ).toEqual( apiResponse ); - } - ); - - expect( promise ).toBeInstanceOf( Promise ); - - await vi.advanceTimersByTimeAsync( 3000 ); - - return promise; - } ); - - it( 'should return a promise that rejects if reached the maximum number of requests to Transifex', async () => { - // 10 is equal to the `MAX_REQUEST_ATTEMPTS` constant. - for ( let i = 0; i < 10; ++i ) { - getResourceStringsAsyncUploadMock.mockResolvedValueOnce( { - attributes: { status: 'processing' } - } ); - } - - const promise = transifexService.getResourceUploadDetails( '4abfc726-6a27-4c33-9d99-e5254c8df748' ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - err => { - expect( err ).toEqual( { - errors: [ - { - detail: 'Failed to retrieve the upload details.' - } - ] - } ); - } - ); - - expect( promise ).toBeInstanceOf( Promise ); - - for ( let i = 0; i < 9; ++i ) { - expect( - getResourceStringsAsyncUploadMock, `getResourceStringsAsyncUpload, call: ${ i + 1 }` - ).toHaveBeenCalledTimes( i + 1 ); - - await vi.advanceTimersByTimeAsync( 3000 ); - } - - return promise; - } ); - } ); -} ); - -function resourceAsyncDownloadMockFactory() { - return { - create: vi.fn().mockImplementation( ( { attributes, relationships, type } ) => { - const resourceName = relationships.resource.attributes.slug; - const languageCode = relationships.language ? relationships.language.attributes.code : 'en'; - - return Promise.resolve( { - attributes, - type, - links: { - self: `https://example.com/${ resourceName }/${ languageCode }` - }, - related: relationships - } ); - } ) - }; -} diff --git a/packages/ckeditor5-dev-transifex/tests/upload.js b/packages/ckeditor5-dev-transifex/tests/upload.js deleted file mode 100644 index 65995558a..000000000 --- a/packages/ckeditor5-dev-transifex/tests/upload.js +++ /dev/null @@ -1,683 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import upload from '../lib/upload.js'; - -import { tools } from '@ckeditor/ckeditor5-dev-utils'; -import { verifyProperties, createLogger } from '../lib/utils.js'; -import chalk from 'chalk'; -import fs from 'fs/promises'; -import path from 'path'; -import transifexService from '../lib/transifexservice.js'; - -const { - tableConstructorSpy, - tablePushMock, - tableToStringMock -} = vi.hoisted( () => { - return { - tableConstructorSpy: vi.fn(), - tablePushMock: vi.fn(), - tableToStringMock: vi.fn() - }; -} ); - -vi.mock( '../lib/transifexservice.js' ); -vi.mock( '../lib/utils.js' ); -vi.mock( '@ckeditor/ckeditor5-dev-utils' ); -vi.mock( 'fs/promises' ); -vi.mock( 'path' ); - -vi.mock( 'chalk', () => ( { - default: { - cyan: vi.fn( string => string ), - gray: vi.fn( string => string ), - italic: vi.fn( string => string ), - underline: vi.fn( string => string ) - } -} ) ); - -vi.mock( 'cli-table', () => { - return { - default: class { - constructor( ...args ) { - tableConstructorSpy( ...args ); - - this.push = tablePushMock; - this.toString = tableToStringMock; - } - } - }; -} ); - -vi.mock( '/home/ckeditor5-with-errors/.transifex-failed-uploads.json', () => ( { - default: { - 'ckeditor5-non-existing-01': [ - 'Resource with this Slug and Project already exists.' - ], - 'ckeditor5-non-existing-02': [ - 'Object not found. It may have been deleted or not been created yet.' - ] - } -} ) ); - -describe( 'dev-transifex/upload()', () => { - let loggerProgressMock, loggerInfoMock, loggerWarningMock, loggerErrorMock, loggerLogMock; - - beforeEach( () => { - vi.mocked( path.join ).mockImplementation( ( ...args ) => args.join( '/' ) ); - - loggerProgressMock = vi.fn(); - loggerInfoMock = vi.fn(); - loggerWarningMock = vi.fn(); - loggerErrorMock = vi.fn(); - loggerErrorMock = vi.fn(); - - vi.mocked( fs.lstat ).mockRejectedValue(); - - vi.mocked( createLogger ).mockImplementation( () => { - return { - progress: loggerProgressMock, - info: loggerInfoMock, - warning: loggerWarningMock, - error: loggerErrorMock, - _log: loggerLogMock - }; - } ); - } ); - - afterEach( () => { - vi.resetAllMocks(); - } ); - - it( 'should reject a promise if required properties are not specified', () => { - const error = new Error( 'The specified object misses the following properties: packages.' ); - const config = { - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( verifyProperties ).mockImplementation( () => { - throw new Error( error ); - } ); - - return upload( config ) - .then( - () => { - throw new Error( 'Expected to be rejected.' ); - }, - caughtError => { - expect( caughtError.message.endsWith( error.message ) ).toEqual( true ); - - expect( vi.mocked( verifyProperties ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( verifyProperties ) ).toHaveBeenCalledWith( - config, [ 'token', 'organizationName', 'projectName', 'cwd', 'packages' ] - ); - } - ); - } ); - - it( 'should store an error log if cannot find the project details', async () => { - const packages = new Map( [ - [ 'ckeditor5-existing-11', 'build/.transifex/ckeditor5-existing-11' ] - ] ); - - vi.mocked( transifexService.getProjectData ).mockRejectedValue( new Error( 'Invalid auth' ) ); - - const config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - await upload( config ); - - expect( vi.mocked( loggerErrorMock ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( loggerErrorMock ) ).toHaveBeenNthCalledWith( - 1, 'Cannot find project details for "ckeditor/ckeditor5".' - ); - expect( vi.mocked( loggerErrorMock ) ).toHaveBeenNthCalledWith( - 2, 'Make sure you specified a valid auth token or an organization/project names.' - ); - - expect( vi.mocked( transifexService.getProjectData ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( transifexService.getProjectData ) ).toHaveBeenCalledWith( - 'ckeditor', 'ckeditor5', [ ...packages.keys() ] - ); - - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledTimes( 0 ); - } ); - - it( 'should create a new resource if the package is processed for the first time', async () => { - const packages = new Map( [ - [ 'ckeditor5-non-existing-01', 'build/.transifex/ckeditor5-non-existing-01' ] - ] ); - - const config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { resources: [] } ); - vi.mocked( transifexService.createResource ).mockResolvedValue(); - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-01' ); - vi.mocked( transifexService.getResourceUploadDetails ).mockResolvedValue( - createResourceUploadDetailsResponse( 'ckeditor5-non-existing-01', 0, 0, 0 ) - ); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: vi.fn(), - finish: vi.fn() - } ); - - await upload( config ); - - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledWith( { - organizationName: 'ckeditor', - projectName: 'ckeditor5', - resourceName: 'ckeditor5-non-existing-01' - } ); - } ); - - it( 'should not create a new resource if the package exists on Transifex', async () => { - const packages = new Map( [ - [ 'ckeditor5-existing-11', 'build/.transifex/ckeditor5-existing-11' ] - ] ); - - const config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { - resources: [ - { attributes: { name: 'ckeditor5-existing-11' } } - ] - } ); - - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-11' ); - - vi.mocked( transifexService.getResourceUploadDetails ).mockResolvedValue( - createResourceUploadDetailsResponse( 'ckeditor5-existing-11', 0, 0, 0 ) - ); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: vi.fn(), - finish: vi.fn() - } ); - - await upload( config ); - - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledTimes( 0 ); - } ); - - it( 'should send a new translation source to Transifex', async () => { - const packages = new Map( [ - [ 'ckeditor5-existing-11', 'build/.transifex/ckeditor5-existing-11' ] - ] ); - - const config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { - resources: [ - { attributes: { name: 'ckeditor5-existing-11' } } - ] - } ); - - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-11' ); - - vi.mocked( transifexService.getResourceUploadDetails ).mockResolvedValue( - createResourceUploadDetailsResponse( 'ckeditor5-existing-11', 0, 0, 0 ) - ); - - vi.mocked( fs.readFile ).mockResolvedValue( '# Example file.' ); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: vi.fn(), - finish: vi.fn() - } ); - - await upload( config ); - - expect( vi.mocked( fs.readFile ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.readFile ) ).toHaveBeenCalledWith( - '/home/ckeditor5/build/.transifex/ckeditor5-existing-11/en.pot', 'utf-8' - ); - - expect( vi.mocked( transifexService.createSourceFile ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( transifexService.createSourceFile ) ).toHaveBeenCalledWith( { - organizationName: 'ckeditor', - projectName: 'ckeditor5', - resourceName: 'ckeditor5-existing-11', - content: '# Example file.' - } ); - - expect( vi.mocked( transifexService.getResourceUploadDetails ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( transifexService.getResourceUploadDetails ) ).toHaveBeenCalledWith( 'uuid-11' ); - } ); - - it( 'should keep informed a developer what the script does', async () => { - const packages = new Map( [ - [ 'ckeditor5-non-existing-01', 'build/.transifex/ckeditor5-non-existing-01' ] - ] ); - - const config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { - resources: [] - } ); - - vi.mocked( transifexService.createResource ).mockResolvedValue(); - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-01' ); - vi.mocked( transifexService.getResourceUploadDetails ).mockResolvedValue( - createResourceUploadDetailsResponse( 'ckeditor5-non-existing-01', 0, 0, 0 ) - ); - - const packageSpinner = { - start: vi.fn(), - finish: vi.fn() - }; - const processSpinner = { - start: vi.fn(), - finish: vi.fn() - }; - - vi.mocked( tools.createSpinner ).mockReturnValueOnce( packageSpinner ); - vi.mocked( tools.createSpinner ).mockReturnValueOnce( processSpinner ); - - vi.mocked( tableToStringMock ).mockReturnValue( '┻━┻' ); - - await upload( config ); - - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( loggerInfoMock ) ).toHaveBeenCalledWith( '┻━┻' ); - - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenCalledTimes( 4 ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( 1, 'Fetching project information...' ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( 2, 'Uploading new translations...' ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( 3 ); - expect( vi.mocked( loggerProgressMock ) ).toHaveBeenNthCalledWith( 4, 'Done.' ); - - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenNthCalledWith( - 1, 'Processing "ckeditor5-non-existing-01"', { emoji: '👉', indentLevel: 1 } - ); - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenNthCalledWith( - 2, 'Collecting responses... It takes a while.' - ); - - expect( packageSpinner.start ).toHaveBeenCalled(); - expect( packageSpinner.finish ).toHaveBeenCalled(); - expect( processSpinner.start ).toHaveBeenCalled(); - expect( processSpinner.finish ).toHaveBeenCalled(); - expect( vi.mocked( chalk.gray ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( chalk.italic ) ).toHaveBeenCalledTimes( 1 ); - } ); - - describe( 'error handling', () => { - let packages, config; - - beforeEach( () => { - packages = new Map( [ - [ 'ckeditor5-non-existing-03', 'build/.transifex/ckeditor5-non-existing-03' ], - [ 'ckeditor5-non-existing-04', 'build/.transifex/ckeditor5-non-existing-04' ], - [ 'ckeditor5-non-existing-01', 'build/.transifex/ckeditor5-non-existing-01' ], - [ 'ckeditor5-non-existing-02', 'build/.transifex/ckeditor5-non-existing-02' ] - ] ); - - config = { - packages, - cwd: '/home/ckeditor5-with-errors', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( fs.lstat ).mockResolvedValueOnce(); - - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { - resources: [] - } ); - - vi.mocked( transifexService.createResource ).mockResolvedValue(); - - vi.mocked( transifexService.createSourceFile ).mockImplementation( options => { - if ( options.resourceName === 'ckeditor5-non-existing-01' ) { - return Promise.resolve( 'uuid-01' ); - } - - if ( options.resourceName === 'ckeditor5-non-existing-02' ) { - return Promise.resolve( 'uuid-02' ); - } - - return Promise.reject( { errors: [] } ); - } ); - - vi.mocked( fs.readFile ).mockImplementation( path => { - if ( path === config.cwd + '/build/.transifex/ckeditor5-non-existing-01/en.pot' ) { - return Promise.resolve( '# ckeditor5-non-existing-01' ); - } - - if ( path === config.cwd + '/build/.transifex/ckeditor5-non-existing-02/en.pot' ) { - return Promise.resolve( '# ckeditor5-non-existing-02' ); - } - - return Promise.resolve( '' ); - } ); - - vi.mocked( transifexService.getResourceUploadDetails ).mockImplementation( id => { - if ( id === 'uuid-01' ) { - return Promise.resolve( - createResourceUploadDetailsResponse( 'ckeditor5-non-existing-01', 3, 0, 0 ) - ); - } - - if ( id === 'uuid-02' ) { - return Promise.resolve( - createResourceUploadDetailsResponse( 'ckeditor5-non-existing-02', 0, 0, 0 ) - ); - } - - return Promise.reject(); - } ); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: vi.fn(), - finish: vi.fn() - } ); - } ); - - it( 'should process packages specified in the ".transifex-failed-uploads.json" file', async () => { - await upload( config ); - - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 1, 'Found the file containing a list of packages that failed during the last script execution.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 2, 'The script will process only packages listed in the file instead of all passed as "config.packages".' - ); - - expect( vi.mocked( fs.readFile ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( transifexService.createSourceFile ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( transifexService.getResourceUploadDetails ) ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'should remove the ".transifex-failed-uploads.json" file if finished with no errors', async () => { - await upload( config ); - - expect( vi.mocked( fs.unlink ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.unlink ) ).toHaveBeenCalledWith( '/home/ckeditor5-with-errors/.transifex-failed-uploads.json' ); - } ); - - it( 'should store an error in the ".transifex-failed-uploads.json" file (cannot create a resource)', async () => { - const firstSpinner = { - start: vi.fn(), - finish: vi.fn() - }; - - vi.mocked( tools.createSpinner ).mockReturnValueOnce( firstSpinner ); - - const error = { - message: 'JsonApiError: 409', - errors: [ - { - detail: 'Resource with this Slug and Project already exists.' - } - ] - }; - - vi.mocked( transifexService.createResource ).mockRejectedValueOnce( error ); - vi.mocked( transifexService.createResource ).mockResolvedValueOnce(); - - await upload( config ); - - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenCalledTimes( 5 ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 3, 'Not all translations were uploaded due to errors in Transifex API.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 4, 'Review the "/home/ckeditor5-with-errors/.transifex-failed-uploads.json" file for more details.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 5, 'Re-running the script will process only packages specified in the file.' - ); - - expect( firstSpinner.finish ).toHaveBeenCalledTimes( 1 ); - expect( firstSpinner.finish ).toHaveBeenCalledWith( { emoji: '❌' } ); - - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledWith( - '/home/ckeditor5-with-errors/.transifex-failed-uploads.json', - JSON.stringify( { - 'ckeditor5-non-existing-01': [ 'Resource with this Slug and Project already exists.' ] - }, null, 2 ) + '\n', - 'utf-8' - ); - } ); - - it( 'should store an error in the ".transifex-failed-uploads.json" file (cannot upload a translation)', async () => { - const firstSpinner = { - start: vi.fn(), - finish: vi.fn() - }; - - vi.mocked( tools.createSpinner ).mockReturnValueOnce( firstSpinner ); - - const error = { - message: 'JsonApiError: 409', - errors: [ - { - detail: 'Object not found. It may have been deleted or not been created yet.' - } - ] - }; - - vi.mocked( transifexService.createSourceFile ).mockRejectedValueOnce( error ); - - await upload( config ); - - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenCalledTimes( 5 ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 3, 'Not all translations were uploaded due to errors in Transifex API.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 4, 'Review the "/home/ckeditor5-with-errors/.transifex-failed-uploads.json" file for more details.' - ); - expect( vi.mocked( loggerWarningMock ) ).toHaveBeenNthCalledWith( - 5, 'Re-running the script will process only packages specified in the file.' - ); - - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledWith( - '/home/ckeditor5-with-errors/.transifex-failed-uploads.json', - JSON.stringify( { - 'ckeditor5-non-existing-01': [ 'Object not found. It may have been deleted or not been created yet.' ] - }, null, 2 ) + '\n', - 'utf-8' - ); - - expect( firstSpinner.finish ).toHaveBeenCalledTimes( 1 ); - expect( firstSpinner.finish ).toHaveBeenCalledWith( { emoji: '❌' } ); - } ); - - it( 'should store an error in the ".transifex-failed-uploads.json" file (cannot get a status of upload)', async () => { - const error = { - message: 'JsonApiError: 409', - errors: [ - { - detail: 'Object not found. It may have been deleted or not been created yet.' - } - ] - }; - - vi.mocked( transifexService.getResourceUploadDetails ).mockRejectedValueOnce( error ); - - await upload( config ); - - expect( loggerWarningMock ).toHaveBeenCalledTimes( 5 ); - expect( loggerWarningMock ).toHaveBeenNthCalledWith( - 3, 'Not all translations were uploaded due to errors in Transifex API.' - ); - expect( loggerWarningMock ).toHaveBeenNthCalledWith( - 4, 'Review the "/home/ckeditor5-with-errors/.transifex-failed-uploads.json" file for more details.' - ); - expect( loggerWarningMock ).toHaveBeenNthCalledWith( - 5, 'Re-running the script will process only packages specified in the file.' - ); - - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs.writeFile ) ).toHaveBeenCalledWith( - '/home/ckeditor5-with-errors/.transifex-failed-uploads.json', - JSON.stringify( { - 'ckeditor5-non-existing-01': [ 'Object not found. It may have been deleted or not been created yet.' ] - }, null, 2 ) + '\n', - 'utf-8' - ); - } ); - } ); - - describe( 'processing multiple packages', () => { - let packages, config; - - beforeEach( () => { - packages = new Map( [ - [ 'ckeditor5-existing-11', 'build/.transifex/ckeditor5-existing-11' ], - [ 'ckeditor5-existing-14', 'build/.transifex/ckeditor5-existing-14' ], - [ 'ckeditor5-non-existing-03', 'build/.transifex/ckeditor5-non-existing-03' ], - [ 'ckeditor5-non-existing-01', 'build/.transifex/ckeditor5-non-existing-01' ], - [ 'ckeditor5-existing-13', 'build/.transifex/ckeditor5-existing-13' ], - [ 'ckeditor5-non-existing-02', 'build/.transifex/ckeditor5-non-existing-02' ], - [ 'ckeditor5-existing-12', 'build/.transifex/ckeditor5-existing-12' ] - ] ); - - config = { - packages, - cwd: '/home/ckeditor5', - token: 'token', - organizationName: 'ckeditor', - projectName: 'ckeditor5' - }; - - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-xx' ); - - vi.mocked( transifexService.createSourceFile ).mockResolvedValue( 'uuid-xx' ); - - // Mock resources on Transifex. - vi.mocked( transifexService.getProjectData ).mockResolvedValue( { - resources: [ - { attributes: { name: 'ckeditor5-existing-11' } }, - { attributes: { name: 'ckeditor5-existing-12' } }, - { attributes: { name: 'ckeditor5-existing-13' } }, - { attributes: { name: 'ckeditor5-existing-14' } } - ] - } ); - - vi.mocked( transifexService.createResource ).mockResolvedValue(); - - vi.mocked( transifexService.getResourceUploadDetails ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-existing-11', 0, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-existing-14', 0, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-non-existing-03', 1, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-non-existing-01', 3, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-existing-13', 2, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-non-existing-02', 0, 0, 0 ) ) - .mockResolvedValueOnce( createResourceUploadDetailsResponse( 'ckeditor5-existing-12', 0, 1, 1 ) ); - - vi.mocked( tools.createSpinner ).mockReturnValue( { - start: vi.fn(), - finish: vi.fn() - } ); - } ); - - it( 'should handle all packages', async () => { - await upload( config ); - - expect( vi.mocked( transifexService.getProjectData ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( transifexService.createResource ) ).toHaveBeenCalledTimes( 3 ); - expect( vi.mocked( transifexService.createSourceFile ) ).toHaveBeenCalledTimes( 7 ); - expect( vi.mocked( transifexService.getResourceUploadDetails ) ).toHaveBeenCalledTimes( 7 ); - expect( vi.mocked( tools.createSpinner ) ).toHaveBeenCalledTimes( 8 ); - } ); - - it( 'should display a summary table with sorted packages (new, has changes, A-Z)', async () => { - await upload( config ); - - expect( vi.mocked( tablePushMock ) ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( tablePushMock ) ).toHaveBeenCalledWith( - [ 'ckeditor5-non-existing-01', '🆕', '3', '0', '0' ], - [ 'ckeditor5-non-existing-03', '🆕', '1', '0', '0' ], - [ 'ckeditor5-non-existing-02', '🆕', '0', '0', '0' ], - [ 'ckeditor5-existing-12', '', '0', '1', '1' ], - [ 'ckeditor5-existing-13', '', '2', '0', '0' ], - [ 'ckeditor5-existing-11', '', '0', '0', '0' ], - [ 'ckeditor5-existing-14', '', '0', '0', '0' ] - ); - - // 1x for printing "It takes a while", - // 5x for each column, x2 for each resource. - expect( vi.mocked( chalk.gray ) ).toHaveBeenCalledTimes( 11 ); - } ); - - it( 'should not display a summary table if none of the packages were processed', async () => { - config.packages = new Map(); - - await upload( config ); - - expect( vi.mocked( tablePushMock ) ).toHaveBeenCalledTimes( 0 ); - } ); - } ); -} ); - -/** - * Returns an object that looks like a response from Transifex API. - * - * @param {string} packageName - * @param {number} created - * @param {number} updated - * @param {number} deleted - * @returns {object} - */ -function createResourceUploadDetailsResponse( packageName, created, updated, deleted ) { - return { - related: { - resource: { - id: `o:ckeditor:p:ckeditor5:r:${ packageName }` - } - }, - attributes: { - details: { - strings_created: created, - strings_updated: updated, - strings_deleted: deleted - } - } - }; -} diff --git a/packages/ckeditor5-dev-transifex/tests/utils.js b/packages/ckeditor5-dev-transifex/tests/utils.js deleted file mode 100644 index 582b02a72..000000000 --- a/packages/ckeditor5-dev-transifex/tests/utils.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { verifyProperties, createLogger } from '../lib/utils.js'; - -import { logger } from '@ckeditor/ckeditor5-dev-utils'; -import chalk from 'chalk'; - -vi.mock( '@ckeditor/ckeditor5-dev-utils' ); -vi.mock( 'chalk', () => ( { - default: { - cyan: vi.fn( string => string ) - } -} ) ); - -describe( 'dev-transifex/utils', () => { - let loggerInfoMock, loggerWarningMock, loggerErrorMock, loggerLogMock; - - beforeEach( async () => { - loggerInfoMock = vi.fn(); - loggerWarningMock = vi.fn(); - loggerErrorMock = vi.fn(); - loggerLogMock = vi.fn(); - - vi.mocked( logger ).mockImplementation( () => { - return { - info: loggerInfoMock, - warning: loggerWarningMock, - error: loggerErrorMock, - _log: loggerLogMock - }; - } ); - } ); - - describe( 'verifyProperties()', () => { - it( 'should throw an error if the specified property is not specified in an object', () => { - expect( () => { - verifyProperties( {}, [ 'foo' ] ); - } ).to.throw( Error, 'The specified object misses the following properties: foo.' ); - } ); - - it( 'should throw an error if the value of the property is `undefined`', () => { - expect( () => { - verifyProperties( { foo: undefined }, [ 'foo' ] ); - } ).to.throw( Error, 'The specified object misses the following properties: foo.' ); - } ); - - it( 'should throw an error containing all The specified object misses the following properties', () => { - expect( () => { - verifyProperties( { foo: true, bar: 0 }, [ 'foo', 'bar', 'baz', 'xxx' ] ); - } ).to.throw( Error, 'The specified object misses the following properties: baz, xxx.' ); - } ); - - it( 'should not throw an error if the value of the property is `null`', () => { - expect( () => { - verifyProperties( { foo: null }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a boolean (`false`)', () => { - expect( () => { - verifyProperties( { foo: false }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a boolean (`true`)', () => { - expect( () => { - verifyProperties( { foo: true }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a number', () => { - expect( () => { - verifyProperties( { foo: 1 }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a number (falsy value)', () => { - expect( () => { - verifyProperties( { foo: 0 }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a NaN', () => { - expect( () => { - verifyProperties( { foo: NaN }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a non-empty string', () => { - expect( () => { - verifyProperties( { foo: 'foo' }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is an empty string', () => { - expect( () => { - verifyProperties( { foo: '' }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is an array', () => { - expect( () => { - verifyProperties( { foo: [] }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is an object', () => { - expect( () => { - verifyProperties( { foo: {} }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - - it( 'should not throw an error if the value of the property is a function', () => { - expect( () => { - verifyProperties( { - foo: () => {} - }, [ 'foo' ] ); - } ).to.not.throw( Error ); - } ); - } ); - - describe( 'createLogger()', () => { - it( 'should be a function', () => { - expect( createLogger ).toBeInstanceOf( Function ); - } ); - - it( 'should return an object with methods', () => { - const logger = createLogger(); - - expect( logger ).toBeInstanceOf( Object ); - expect( logger.progress ).toBeInstanceOf( Function ); - expect( logger.info ).toBeInstanceOf( Function ); - expect( logger.warning ).toBeInstanceOf( Function ); - expect( logger.error ).toBeInstanceOf( Function ); - expect( logger._log ).toBeInstanceOf( Function ); - } ); - - it( 'should call the info method for a non-empty progress message', () => { - const logger = createLogger(); - - logger.progress( 'Example step.' ); - - expect( loggerInfoMock ).toHaveBeenCalledTimes( 1 ); - expect( loggerInfoMock ).toHaveBeenCalledWith( '\n📍 Example step.' ); - expect( chalk.cyan ).toHaveBeenCalledTimes( 1 ); - expect( chalk.cyan ).toHaveBeenCalledWith( 'Example step.' ); - } ); - - it( 'should call the info method with an empty message for an empty progress message', () => { - const logger = createLogger(); - - logger.progress(); - - expect( loggerInfoMock ).toHaveBeenCalledTimes( 1 ); - expect( loggerInfoMock ).toHaveBeenCalledWith( '' ); - expect( chalk.cyan ).toHaveBeenCalledTimes( 0 ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-dev-transifex/vitest.config.js b/packages/ckeditor5-dev-transifex/vitest.config.js deleted file mode 100644 index 5ad784a28..000000000 --- a/packages/ckeditor5-dev-transifex/vitest.config.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { defineConfig } from 'vitest/config'; - -export default defineConfig( { - test: { - testTimeout: 10000, - restoreMocks: true, - include: [ - 'tests/**/*.js' - ], - coverage: { - provider: 'v8', - include: [ - 'lib/**' - ], - reporter: [ 'text', 'json', 'html', 'lcov' ] - } - } -} ); diff --git a/packages/ckeditor5-dev-translations/lib/cleanpofilecontent.js b/packages/ckeditor5-dev-translations/lib/cleanpofilecontent.js deleted file mode 100644 index e695ab4e6..000000000 --- a/packages/ckeditor5-dev-translations/lib/cleanpofilecontent.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import PO from 'pofile'; - -/** - * Returns translations stripped from the personal data, but with an added banner - * containing information where to add new translations or fix the existing ones. - * - * @param {string} poFileContent Content of the translation file. - * @param {object} [options={}] - * @param {boolean} [options.simplifyLicenseHeader] Whether to skip adding the contribute URL in the header. - * @returns {string} - */ -export default function cleanPoFileContent( poFileContent, options = {} ) { - const po = PO.parse( poFileContent ); - - // Remove personal data from headers. - po.headers = { - Language: po.headers.Language, - 'Language-Team': po.headers[ 'Language-Team' ], - 'Plural-Forms': po.headers[ 'Plural-Forms' ], - 'Content-Type': 'text/plain; charset=UTF-8' - }; - - const copyright = po.comments.find( comment => comment.includes( 'Copyright' ) ); - - // Clean comments. - po.comments = [ - ' !!! IMPORTANT !!!', - '', - ' Before you edit this file, please keep in mind that contributing to the project', - ' translations is possible ONLY via the Transifex online service.', - '' - ]; - - if ( copyright ) { - po.comments.unshift( copyright, '' ); - } - - if ( !options.simplifyLicenseHeader ) { - po.comments.push( - ' To submit your translations, visit https://www.transifex.com/ckeditor/ckeditor5.', - '', - ' To learn more, check out the official contributor\'s guide:', - ' https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html', - '' - ); - } - - return po.toString(); -} diff --git a/packages/ckeditor5-dev-translations/lib/createdictionaryfrompofilecontent.js b/packages/ckeditor5-dev-translations/lib/createdictionaryfrompofilecontent.js deleted file mode 100644 index 9cf6a83a3..000000000 --- a/packages/ckeditor5-dev-translations/lib/createdictionaryfrompofilecontent.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import PO from 'pofile'; - -/** - * Returns object with key-value pairs from parsed po file. - * - * @param {string} poFileContent Content of the translation file. - * @returns {Object.} - */ -export default function createDictionaryFromPoFileContent( poFileContent ) { - const po = PO.parse( poFileContent ); - - const keys = {}; - - for ( const item of po.items ) { - if ( item.msgstr[ 0 ] ) { - // Return the whole msgstr array to collect the single form and all plural forms. - keys[ item.msgid ] = item.msgstr; - } - } - - return keys; -} diff --git a/packages/ckeditor5-dev-translations/lib/index.js b/packages/ckeditor5-dev-translations/lib/index.js index db5991323..d43793755 100644 --- a/packages/ckeditor5-dev-translations/lib/index.js +++ b/packages/ckeditor5-dev-translations/lib/index.js @@ -4,7 +4,7 @@ */ export { default as findMessages } from './findmessages.js'; -export { default as cleanPoFileContent } from './cleanpofilecontent.js'; export { default as MultipleLanguageTranslationService } from './multiplelanguagetranslationservice.js'; -export { default as createDictionaryFromPoFileContent } from './createdictionaryfrompofilecontent.js'; export { default as CKEditorTranslationsPlugin } from './ckeditortranslationsplugin.js'; +export { default as synchronizeTranslations } from './synchronizetranslations.js'; +export { default as moveTranslations } from './movetranslations.js'; diff --git a/packages/ckeditor5-dev-translations/lib/movetranslations.js b/packages/ckeditor5-dev-translations/lib/movetranslations.js new file mode 100644 index 000000000..6ea417e05 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/movetranslations.js @@ -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.} 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.} options.config Configuration that defines the messages to move. + * @returns {Array.} + */ +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.} options.config Configuration that defines the messages to move. + * @returns {Array.} + */ +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.} options.packageContexts An array of language contexts. + * @param {Array.} options.config Configuration that defines the messages to move. + * @returns {Array.} + */ +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. + */ diff --git a/packages/ckeditor5-dev-translations/lib/synchronizetranslations.js b/packages/ckeditor5-dev-translations/lib/synchronizetranslations.js new file mode 100644 index 000000000..a940a1d64 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/synchronizetranslations.js @@ -0,0 +1,178 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import upath from 'upath'; +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 synchronizeTranslationsBasedOnContext from './utils/synchronizetranslationsbasedoncontext.js'; + +/** + * Synchronizes translations in provided packages by performing the following steps: + * * Collect all i18n messages from all provided packages by finding `t()` calls in source files. + * * Detect if translation context is valid, i.e. whether there is no missing, unused or duplicated context. + * * If there are no validation errors, update all translation files ("*.po" files) to be in sync with the context file: + * * unused translation entries are removed, + * * missing translation entries are added with empty string as the message translation, + * * missing translation files are created for languages that do not have own "*.po" file yet. + * + * @param {object} options + * @param {Array.} options.sourceFiles An array of source files that contain messages to translate. + * @param {Array.} 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. + * @param {boolean} [options.ignoreUnusedCorePackageContexts=false] Whether to skip unused context errors related to + * the `@ckeditor/ckeditor5-core` package. + * @param {boolean} [options.validateOnly=false] If set, only validates the translations contexts against the source messages without + * synchronizing the translations. + * @param {boolean} [options.skipLicenseHeader=false] Whether to skip adding the license header to newly created translation files. + */ +export default function synchronizeTranslations( options ) { + const { + sourceFiles, + packagePaths, + corePackagePath, + ignoreUnusedCorePackageContexts = false, + validateOnly = false, + skipLicenseHeader = false + } = options; + + const errors = []; + const log = logger(); + + log.info( '📍 Loading translations contexts...' ); + const packageContexts = getPackageContexts( { packagePaths, corePackagePath } ); + + log.info( '📍 Loading messages from source files...' ); + const sourceMessages = getSourceMessages( { packagePaths, sourceFiles, onErrorCallback: error => errors.push( error ) } ); + + log.info( '📍 Validating translations contexts against the source messages...' ); + errors.push( + ...assertNoMissingContext( { packageContexts, sourceMessages, corePackagePath } ), + ...assertAllContextUsed( { packageContexts, sourceMessages, corePackagePath, ignoreUnusedCorePackageContexts } ), + ...assertNoRepeatedContext( { packageContexts } ) + ); + + if ( errors.length ) { + log.error( '🔥 The following errors have been found:' ); + + for ( const error of errors ) { + log.error( ` - ${ error }` ); + } + + process.exit( 1 ); + } + + if ( validateOnly ) { + log.info( '✨ No errors found.' ); + + return; + } + + log.info( '📍 Synchronizing translations files...' ); + synchronizeTranslationsBasedOnContext( { packageContexts, sourceMessages, skipLicenseHeader } ); + + log.info( '✨ Done.' ); +} + +/** + * @param {object} options + * @param {Array.} options.packageContexts An array of language contexts. + * @param {Array.} 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.} + */ +function assertNoMissingContext( { packageContexts, sourceMessages, corePackagePath } ) { + const contextMessageIdsGroupedByPackage = packageContexts.reduce( ( result, context ) => { + result[ context.packagePath ] = Object.keys( context.contextContent ); + + return result; + }, {} ); + + return sourceMessages + .filter( message => { + const contextMessageIds = [ + ...contextMessageIdsGroupedByPackage[ message.packagePath ], + ...contextMessageIdsGroupedByPackage[ corePackagePath ] + ]; + + return !contextMessageIds.includes( message.id ); + } ) + .map( message => `Missing context "${ message.id }" in "${ message.filePath }".` ); +} + +/** + * @param {object} options + * @param {Array.} options.packageContexts An array of language contexts. + * @param {Array.} 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. + * @returns {Array.} + */ +function assertAllContextUsed( { packageContexts, sourceMessages, corePackagePath, ignoreUnusedCorePackageContexts } ) { + const sourceMessageIds = sourceMessages.map( message => message.id ); + + const sourceMessageIdsGroupedByPackage = sourceMessages.reduce( ( result, message ) => { + result[ message.packagePath ] = result[ message.packagePath ] || []; + result[ message.packagePath ].push( message.id ); + + return result; + }, {} ); + + return packageContexts + .flatMap( context => { + const { packagePath, contextContent } = context; + const messageIds = Object.keys( contextContent ); + + return messageIds.map( messageId => ( { messageId, packagePath } ) ); + } ) + .filter( ( { messageId, packagePath } ) => { + if ( packagePath === corePackagePath ) { + return !sourceMessageIds.includes( messageId ); + } + + if ( !sourceMessageIdsGroupedByPackage[ packagePath ] ) { + return true; + } + + return !sourceMessageIdsGroupedByPackage[ packagePath ].includes( messageId ); + } ) + .filter( ( { packagePath } ) => { + if ( ignoreUnusedCorePackageContexts && packagePath === corePackagePath ) { + return false; + } + + return true; + } ) + .map( ( { messageId, packagePath } ) => `Unused context "${ messageId }" in "${ upath.join( packagePath, CONTEXT_FILE_PATH ) }".` ); +} + +/** + * @param {object} options + * @param {Array.} options.packageContexts An array of language contexts. + * @returns {Array.} + */ +function assertNoRepeatedContext( { packageContexts } ) { + const contextMessageIds = packageContexts + .flatMap( context => { + const { contextFilePath, contextContent } = context; + const messageIds = Object.keys( contextContent ); + + return messageIds.map( messageId => ( { messageId, contextFilePath } ) ); + } ) + .reduce( ( result, { messageId, contextFilePath } ) => { + result[ messageId ] = result[ messageId ] || []; + result[ messageId ].push( contextFilePath ); + + return result; + }, {} ); + + return Object.entries( contextMessageIds ) + .filter( ( [ , contextFilePaths ] ) => contextFilePaths.length > 1 ) + .map( ( [ messageId, contextFilePaths ] ) => { + return `Duplicated context "${ messageId }" in "${ contextFilePaths.join( '", "' ) }".`; + } ); +} diff --git a/packages/ckeditor5-dev-translations/lib/templates/translation.po b/packages/ckeditor5-dev-translations/lib/templates/translation.po new file mode 100644 index 000000000..5ead76f68 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/templates/translation.po @@ -0,0 +1,7 @@ +# Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. +# +# Want to contribute to this file? Submit your changes via a GitHub Pull Request. +# +# Check out the official contributor's guide: +# https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html +# diff --git a/packages/ckeditor5-dev-translations/lib/utils/cleantranslationfilecontent.js b/packages/ckeditor5-dev-translations/lib/utils/cleantranslationfilecontent.js new file mode 100644 index 000000000..2943a1836 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/cleantranslationfilecontent.js @@ -0,0 +1,20 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * Removes unused headers from the translation file. + * + * @param {import('pofile')} translationFileContent Content of the translation file. + * @returns {import('pofile')} + */ +export default function cleanTranslationFileContent( translationFileContent ) { + translationFileContent.headers = { + Language: translationFileContent.headers.Language, + 'Plural-Forms': translationFileContent.headers[ 'Plural-Forms' ], + 'Content-Type': 'text/plain; charset=UTF-8' + }; + + return translationFileContent; +} diff --git a/packages/ckeditor5-dev-translations/lib/utils/constants.js b/packages/ckeditor5-dev-translations/lib/utils/constants.js new file mode 100644 index 000000000..806583893 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/constants.js @@ -0,0 +1,7 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +export const CONTEXT_FILE_PATH = 'lang/contexts.json'; +export const TRANSLATION_FILES_PATH = 'lang/translations'; diff --git a/packages/ckeditor5-dev-translations/lib/utils/createmissingpackagetranslations.js b/packages/ckeditor5-dev-translations/lib/utils/createmissingpackagetranslations.js new file mode 100644 index 000000000..6097ff6f2 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/createmissingpackagetranslations.js @@ -0,0 +1,45 @@ +/** + * @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 { fileURLToPath } from 'url'; +import { getNPlurals, getFormula } from 'plural-forms'; +import getLanguages from './getlanguages.js'; +import { TRANSLATION_FILES_PATH } from './constants.js'; +import cleanTranslationFileContent from './cleantranslationfilecontent.js'; + +const __filename = fileURLToPath( import.meta.url ); +const __dirname = upath.dirname( __filename ); + +const TRANSLATION_TEMPLATE_PATH = upath.join( __dirname, '../templates/translation.po' ); + +/** + * @param {object} options + * @param {string} options.packagePath Path to the package to check for missing translations. + * @param {boolean} options.skipLicenseHeader Whether to skip adding the license header to newly created translation files. + */ +export default function createMissingPackageTranslations( { packagePath, skipLicenseHeader } ) { + const translationsTemplate = skipLicenseHeader ? '' : fs.readFileSync( TRANSLATION_TEMPLATE_PATH, 'utf-8' ); + + for ( const { localeCode, languageCode, languageFileName } of getLanguages() ) { + const translationFilePath = upath.join( packagePath, TRANSLATION_FILES_PATH, `${ languageFileName }.po` ); + + if ( fs.existsSync( translationFilePath ) ) { + continue; + } + + const translations = PO.parse( translationsTemplate ); + + translations.headers.Language = localeCode; + translations.headers[ 'Plural-Forms' ] = [ + `nplurals=${ getNPlurals( languageCode ) };`, + `plural=${ getFormula( languageCode ) };` + ].join( ' ' ); + + fs.outputFileSync( translationFilePath, cleanTranslationFileContent( translations ).toString(), 'utf-8' ); + } +} diff --git a/packages/ckeditor5-dev-translations/lib/utils/getlanguages.js b/packages/ckeditor5-dev-translations/lib/utils/getlanguages.js new file mode 100644 index 000000000..8259687fc --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/getlanguages.js @@ -0,0 +1,110 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +const SUPPORTED_LOCALES = [ + 'en', // English + 'af', // Afrikaans + 'sq', // Albanian + 'ar', // Arabic + 'hy', // Armenian + 'ast', // Asturian + 'az', // Azerbaijani + 'eu', // Basque + 'bn', // Bengali + 'bs', // Bosnian + 'bg', // Bulgarian + 'ca', // Catalan + 'zh_CN', // Chinese (China) + 'zh_TW', // Chinese (Taiwan) + 'hr', // Croatian + 'cs', // Czech + 'da', // Danish + 'nl', // Dutch + 'en_AU', // English (Australia) + 'en_GB', // English (United Kingdom) + 'eo', // Esperanto + 'et', // Estonian + 'fi', // Finnish + 'fr', // French + 'gl', // Galician + 'de', // German + 'de_CH', // German (Switzerland) + 'el', // Greek + 'gu', // Gujarati + 'he', // Hebrew + 'hi', // Hindi + 'hu', // Hungarian + 'id', // Indonesian + 'it', // Italian + 'ja', // Japanese + 'jv', // Javanese + 'kn', // Kannada + 'kk', // Kazakh + 'km', // Khmer + 'ko', // Korean + 'ku', // Kurdish + 'lv', // Latvian + 'lt', // Lithuanian + 'ms', // Malay + 'ne_NP', // Nepali (Nepal) + 'no', // Norwegian + 'nb', // Norwegian Bokmål + 'oc', // Occitan (post 1500) + 'fa', // Persian + 'pl', // Polish + 'pt', // Portuguese + 'pt_BR', // Portuguese (Brazil) + 'ro', // Romanian + 'ru', // Russian + 'sr', // Serbian + 'sr@latin', // Serbian (Latin) + 'si_LK', // Sinhala (Sri Lanka) + 'sk', // Slovak + 'sl', // Slovenian + 'es', // Spanish + 'es_CO', // Spanish (Colombia) + 'sv', // Swedish + 'tt', // Tatar + 'th', // Thai + 'ti', // Tigrinya + 'tr', // Turkish + 'tk', // Turkmen + 'uk', // Ukrainian + 'ur', // Urdu + 'ug', // Uyghur + 'uz', // Uzbek + 'vi' // Vietnamese +]; + +const LOCALES_FILENAME_MAP = { + 'ne_NP': 'ne', + 'si_LK': 'si', + 'sr@latin': 'sr-latn', + 'zh_TW': 'zh' +}; + +/** + * @returns {Array.} + */ +export default function getLanguages() { + return SUPPORTED_LOCALES.map( localeCode => { + const languageCode = localeCode.split( /[-_@]/ )[ 0 ]; + const languageFileName = LOCALES_FILENAME_MAP[ localeCode ] || localeCode.toLowerCase().replace( /[^a-z0-9]+/, '-' ); + + return { + localeCode, + languageCode, + languageFileName + }; + } ); +} + +/** + * @typedef {object} Language + * + * @property {string} localeCode + * @property {string} languageCode + * @property {string} languageFileName + */ diff --git a/packages/ckeditor5-dev-translations/lib/utils/getpackagecontext.js b/packages/ckeditor5-dev-translations/lib/utils/getpackagecontext.js new file mode 100644 index 000000000..f97d745d3 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/getpackagecontext.js @@ -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 + */ diff --git a/packages/ckeditor5-dev-translations/lib/utils/getpackagecontexts.js b/packages/ckeditor5-dev-translations/lib/utils/getpackagecontexts.js new file mode 100644 index 000000000..cce345a21 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/getpackagecontexts.js @@ -0,0 +1,22 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import getPackageContext from './getpackagecontext.js'; + +/** + * @param {object} options + * @param {Array.} 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.} + */ +export default function getPackageContexts( { packagePaths, corePackagePath } ) { + // Add path to the core package if not included in the package paths. + // The core package contains common contexts shared between other packages. + if ( !packagePaths.includes( corePackagePath ) ) { + packagePaths.push( corePackagePath ); + } + + return packagePaths.map( packagePath => getPackageContext( { packagePath } ) ); +} diff --git a/packages/ckeditor5-dev-translations/lib/utils/getsourcemessages.js b/packages/ckeditor5-dev-translations/lib/utils/getsourcemessages.js new file mode 100644 index 000000000..6336774e5 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/getsourcemessages.js @@ -0,0 +1,43 @@ +/** + * @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 findMessages from '../findmessages.js'; + +/** + * @param {object} options + * @param {Array.} options.packagePaths An array of paths to packages that contain source files with messages to translate. + * @param {Array.} 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.} + */ +export default function getSourceMessages( { packagePaths, sourceFiles, onErrorCallback } ) { + return sourceFiles + .filter( filePath => packagePaths.some( packagePath => filePath.includes( `/${ packagePath }/` ) ) ) + .flatMap( filePath => { + const fileContent = fs.readFileSync( filePath, 'utf-8' ); + const packagePath = packagePaths.find( packagePath => filePath.includes( `/${ packagePath }/` ) ); + const sourceMessages = []; + + const onMessageCallback = message => { + sourceMessages.push( { filePath, packagePath, ...message } ); + }; + + findMessages( fileContent, filePath, onMessageCallback, onErrorCallback ); + + return sourceMessages; + } ); +} + +/** + * @typedef {object} TranslatableEntry + * + * @property {string} id + * @property {string} string + * @property {string} filePath + * @property {string} packagePath + * @property {string} context + * @property {string} [plural] + */ diff --git a/packages/ckeditor5-dev-translations/lib/utils/movetranslationsbetweenpackages.js b/packages/ckeditor5-dev-translations/lib/utils/movetranslationsbetweenpackages.js new file mode 100644 index 000000000..1e5fceddb --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/movetranslationsbetweenpackages.js @@ -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.} options.packageContexts An array of language contexts. + * @param {Array.} 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' } ); + } +} diff --git a/packages/ckeditor5-dev-translations/lib/utils/synchronizetranslationsbasedoncontext.js b/packages/ckeditor5-dev-translations/lib/utils/synchronizetranslationsbasedoncontext.js new file mode 100644 index 000000000..31d2df852 --- /dev/null +++ b/packages/ckeditor5-dev-translations/lib/utils/synchronizetranslationsbasedoncontext.js @@ -0,0 +1,80 @@ +/** + * @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 createMissingPackageTranslations from './createmissingpackagetranslations.js'; +import { TRANSLATION_FILES_PATH } from './constants.js'; +import cleanTranslationFileContent from './cleantranslationfilecontent.js'; + +/** + * @param {object} options + * @param {Array.} options.packageContexts An array of language contexts. + * @param {Array.} 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 synchronizeTranslationsBasedOnContext( { packageContexts, sourceMessages, skipLicenseHeader } ) { + // For each package: + for ( const { packagePath, contextContent } of packageContexts ) { + // (1) Skip packages that do not contain language context. + const hasContext = Object.keys( contextContent ).length > 0; + + if ( !hasContext ) { + continue; + } + + // (2) Create missing translation files for languages that do not have own "*.po" file yet. + createMissingPackageTranslations( { packagePath, skipLicenseHeader } ); + + // (3) Find all source messages that are defined in the language context. + const sourceMessagesForPackage = Object.keys( contextContent ) + .map( messageId => sourceMessages.find( message => message.id === messageId ) ) + .filter( Boolean ); + + // (4) Find all translation files ("*.po" files). + const translationFilePaths = glob.sync( upath.join( packagePath, TRANSLATION_FILES_PATH, '*.po' ) ); + + // Then, for each translation file in a package: + for ( const translationFilePath of translationFilePaths ) { + const translationFile = fs.readFileSync( translationFilePath, 'utf-8' ); + const translations = PO.parse( translationFile ); + + // (4.1) Remove unused translations. + translations.items = translations.items.filter( item => contextContent[ item.msgid ] ); + + // (4.2) Add missing translations. + translations.items.push( + ...sourceMessagesForPackage + .filter( message => !translations.items.find( item => item.msgid === message.id ) ) + .map( message => { + const numberOfPluralForms = PO.parsePluralForms( translations.headers[ 'Plural-Forms' ] ).nplurals; + const item = new PO.Item( { nplurals: numberOfPluralForms } ); + + item.msgctxt = contextContent[ message.id ]; + item.msgid = message.id; + item.msgstr.push( '' ); + + if ( message.plural ) { + item.msgid_plural = message.plural; + item.msgstr.push( ...Array( numberOfPluralForms - 1 ).fill( '' ) ); + } + + return item; + } ) + ); + + const translationFileUpdated = cleanTranslationFileContent( translations ).toString(); + + // (4.3) Save translation file only if it has been updated. + if ( translationFile === translationFileUpdated ) { + continue; + } + + fs.writeFileSync( translationFilePath, translationFileUpdated, 'utf-8' ); + } + } +} diff --git a/packages/ckeditor5-dev-translations/package.json b/packages/ckeditor5-dev-translations/package.json index bef079128..a43a94188 100644 --- a/packages/ckeditor5-dev-translations/package.json +++ b/packages/ckeditor5-dev-translations/package.json @@ -24,11 +24,15 @@ "dependencies": { "@babel/parser": "^7.18.9", "@babel/traverse": "^7.18.9", + "@ckeditor/ckeditor5-dev-utils": "^44.0.0", "chalk": "^5.0.0", "fs-extra": "^11.0.0", + "glob": "^10.0.0", "rimraf": "^5.0.0", "webpack-sources": "^3.0.0", - "pofile": "^1.0.9" + "plural-forms": "^0.5.5", + "pofile": "^1.0.9", + "upath": "^2.0.1" }, "devDependencies": { "vitest": "^2.0.5" diff --git a/packages/ckeditor5-dev-translations/tests/cleanpofilecontent.js b/packages/ckeditor5-dev-translations/tests/cleanpofilecontent.js deleted file mode 100644 index 77a7d3774..000000000 --- a/packages/ckeditor5-dev-translations/tests/cleanpofilecontent.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import cleanPoFileContent from '../lib/cleanpofilecontent.js'; - -describe( 'translations', () => { - describe( 'cleanPoFileContent()', () => { - it( 'cleans po files from personal data and add the special header', () => { - const poFileContent = -// eslint-disable-next-line max-len -`# Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. -# Translators: -# Xuxxx Satxxx , 2017 -msgid "" -msgstr "" -"Last-Translator: Xuxxx Satxxx , 2017\\n" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const expectedResult = -// eslint-disable-next-line max-len -`# Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. -# -# !!! IMPORTANT !!! -# -# Before you edit this file, please keep in mind that contributing to the project -# translations is possible ONLY via the Transifex online service. -# -# To submit your translations, visit https://www.transifex.com/ckeditor/ckeditor5. -# -# To learn more, check out the official contributor's guide: -# https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html -# -msgid "" -msgstr "" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const result = cleanPoFileContent( poFileContent ); - - expect( result ).to.equal( expectedResult ); - } ); - - it( 'does not add the copyright line if the source file misses it', () => { - const poFileContent = -// eslint-disable-next-line max-len -`# Translators: -# Xuxxx Satxxx , 2017 -msgid "" -msgstr "" -"Last-Translator: Xuxxx Satxxx , 2017\\n" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const expectedResult = -// eslint-disable-next-line max-len -`# !!! IMPORTANT !!! -# -# Before you edit this file, please keep in mind that contributing to the project -# translations is possible ONLY via the Transifex online service. -# -# To submit your translations, visit https://www.transifex.com/ckeditor/ckeditor5. -# -# To learn more, check out the official contributor's guide: -# https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html -# -msgid "" -msgstr "" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const result = cleanPoFileContent( poFileContent ); - - expect( result ).to.equal( expectedResult ); - } ); - - it( 'removes the contribute url when passing options.simplifyLicenseHeader=true ', () => { - const poFileContent = -`# Translators: -# Xuxxx Satxxx , 2017 -msgid "" -msgstr "" -"Last-Translator: Xuxxx Satxxx , 2017\\n" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const expectedResult = -`# !!! IMPORTANT !!! -# -# Before you edit this file, please keep in mind that contributing to the project -# translations is possible ONLY via the Transifex online service. -# -msgid "" -msgstr "" -"Language: ast\\n" -"Language-Team: Asturian (https://www.transifex.com/ckeditor/teams/11143/ast/)\\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\\n" -"Content-Type: text/plain; charset=UTF-8\\n" - -msgctxt "Label for the url input in the Link dialog." -msgid "Link URL" -msgstr "URL del enllaz" -`; - - const result = cleanPoFileContent( poFileContent, { simplifyLicenseHeader: true } ); - - expect( result ).to.equal( expectedResult ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-dev-translations/tests/createdictionaryfrompofilecontent.js b/packages/ckeditor5-dev-translations/tests/createdictionaryfrompofilecontent.js deleted file mode 100644 index 2e243e57e..000000000 --- a/packages/ckeditor5-dev-translations/tests/createdictionaryfrompofilecontent.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import { describe, expect, it } from 'vitest'; -import createDictionaryFromPoFileContent from '../lib/createdictionaryfrompofilecontent.js'; - -describe( 'translations', () => { - describe( 'parsePoFileContent()', () => { - // More functional rather than unit test to check whole conversion process. - it( 'should parse content and return js object with key - value pairs', () => { - const result = createDictionaryFromPoFileContent( [ - 'msgctxt "Label for the Save button."', - 'msgid "Save"', - 'msgstr "Zapisz"', - '', - 'msgctxt "Label for the Cancel button."', - 'msgid "Cancel"', - 'msgstr "Anuluj"', - '' - ].join( '\n' ) ); - - expect( result ).to.deep.equal( { - Save: [ 'Zapisz' ], - Cancel: [ 'Anuluj' ] - } ); - } ); - - it( 'should skip the objects that do not contain msgstr property', () => { - const result = createDictionaryFromPoFileContent( [ - 'msgctxt "Label for the Save button."', - 'msgid "Save"', - 'msgstr ""', - '' - ].join( '\n' ) ); - - expect( result ).to.deep.equal( {} ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-dev-translations/tests/movetranslations.js b/packages/ckeditor5-dev-translations/tests/movetranslations.js new file mode 100644 index 000000000..b5f381bd2 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/movetranslations.js @@ -0,0 +1,301 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import getPackageContext from '../lib/utils/getpackagecontext.js'; +import moveTranslationsBetweenPackages from '../lib/utils/movetranslationsbetweenpackages.js'; +import moveTranslations from '../lib/movetranslations.js'; + +const stubs = vi.hoisted( () => { + return { + logger: { + info: vi.fn(), + error: vi.fn() + } + }; +} ); + +vi.mock( 'fs-extra' ); +vi.mock( '@ckeditor/ckeditor5-dev-utils', () => ( { + logger: vi.fn( () => stubs.logger ) +} ) ); +vi.mock( '../lib/utils/getpackagecontext.js' ); +vi.mock( '../lib/utils/movetranslationsbetweenpackages.js' ); + +describe( 'moveTranslations()', () => { + let defaultOptions; + + beforeEach( () => { + defaultOptions = { + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + } + ] + }; + + vi.mocked( fs.existsSync ).mockReturnValue( true ); + + vi.mocked( getPackageContext ).mockImplementation( ( { packagePath } ) => { + const contextContent = {}; + + if ( packagePath === 'packages/ckeditor5-foo' ) { + contextContent.id1 = 'Context for message id1 from "ckeditor5-foo".'; + } + + if ( packagePath === 'packages/ckeditor5-bar' ) { + contextContent.id2 = 'Context for message id2 from "ckeditor5-bar".'; + } + + return { + contextContent, + contextFilePath: packagePath + '/lang/contexts.json', + packagePath + }; + } ); + + vi.spyOn( process, 'exit' ).mockImplementation( () => {} ); + } ); + + it( 'should be a function', () => { + expect( moveTranslations ).toBeInstanceOf( Function ); + } ); + + it( 'should load translations contexts', () => { + moveTranslations( defaultOptions ); + + expect( getPackageContext ).toHaveBeenCalledTimes( 2 ); + expect( getPackageContext ).toHaveBeenCalledWith( { packagePath: 'packages/ckeditor5-foo' } ); + expect( getPackageContext ).toHaveBeenCalledWith( { packagePath: 'packages/ckeditor5-bar' } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Loading translations contexts...' ); + } ); + + it( 'should move translations between packages', () => { + moveTranslations( defaultOptions ); + + expect( moveTranslationsBetweenPackages ).toHaveBeenCalledTimes( 1 ); + expect( moveTranslationsBetweenPackages ).toHaveBeenCalledWith( { + packageContexts: [ + { + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".' + }, + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + packagePath: 'packages/ckeditor5-foo' + }, + { + contextContent: { + id2: 'Context for message id2 from "ckeditor5-bar".' + }, + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + packagePath: 'packages/ckeditor5-bar' + } + ], + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + } + ] + } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Moving translations between packages...' ); + } ); + + describe( 'validation', () => { + describe( 'unique move entries', () => { + it( 'should return no error if there are unique entries (one entry, no duplicates)', () => { + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Duplicated entry' ) ); + } ); + + it( 'should return no error if there are unique entries (many entries, no duplicates)', () => { + defaultOptions = { + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + }, + { + source: 'packages/ckeditor5-bar', + destination: 'packages/ckeditor5-foo', + messageId: 'id2' + } + ] + }; + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Duplicated entry' ) ); + } ); + + it( 'should return error if there are duplicated entries (many entries, one duplicated entry)', () => { + defaultOptions = { + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + }, + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + } + ] + }; + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Duplicated entry: the "id1" message is configured to be moved multiple times.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error once for each duplicated entry (many entries, many repeated duplicated entries)', () => { + defaultOptions = { + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + }, + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + }, + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + } + ] + }; + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Duplicated entry: the "id1" message is configured to be moved multiple times.' + ); + + const callsWithDuplicatedEntryLog = stubs.logger.error.mock.calls.filter( call => { + const [ arg ] = call; + + return arg.includes( 'Duplicated entry' ); + } ); + + expect( callsWithDuplicatedEntryLog.length ).toEqual( 1 ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + + describe( 'packages exist', () => { + it( 'should return no error if there is no missing package', () => { + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing package' ) ); + } ); + + it( 'should return error if there is missing package (missing source package)', () => { + vi.mocked( fs.existsSync ).mockImplementation( path => { + return path !== 'packages/ckeditor5-foo'; + } ); + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing package: the "packages/ckeditor5-foo" package does not exist.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is missing package (missing destination package)', () => { + vi.mocked( fs.existsSync ).mockImplementation( path => { + return path !== 'packages/ckeditor5-bar'; + } ); + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing package: the "packages/ckeditor5-bar" package does not exist.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is missing package (missing source and destination packages)', () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing package: the "packages/ckeditor5-foo" package does not exist.' + ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing package: the "packages/ckeditor5-bar" package does not exist.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + + describe( 'context exists', () => { + it( 'should return no error if there is no missing context', () => { + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return error if there is missing context (message id does not exist)', () => { + defaultOptions.config = [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id100' + } + ]; + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing context: the "id100" message does not exist in "packages/ckeditor5-foo" package.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is missing context (message id exists only in destination package)', () => { + defaultOptions.config = [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id2' + } + ]; + + moveTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing context: the "id2" message does not exist in "packages/ckeditor5-foo" package.' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/synchronizetranslations.js b/packages/ckeditor5-dev-translations/tests/synchronizetranslations.js new file mode 100644 index 000000000..86e8e3c60 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/synchronizetranslations.js @@ -0,0 +1,807 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import getPackageContexts from '../lib/utils/getpackagecontexts.js'; +import getSourceMessages from '../lib/utils/getsourcemessages.js'; +import synchronizeTranslationsBasedOnContext from '../lib/utils/synchronizetranslationsbasedoncontext.js'; +import synchronizeTranslations from '../lib/synchronizetranslations.js'; + +const stubs = vi.hoisted( () => { + return { + logger: { + info: vi.fn(), + error: vi.fn() + } + }; +} ); + +vi.mock( '@ckeditor/ckeditor5-dev-utils', () => ( { + logger: vi.fn( () => stubs.logger ) +} ) ); +vi.mock( '../lib/utils/getpackagecontexts.js' ); +vi.mock( '../lib/utils/getsourcemessages.js' ); +vi.mock( '../lib/utils/synchronizetranslationsbasedoncontext.js' ); + +describe( 'synchronizeTranslations()', () => { + let defaultOptions; + + beforeEach( () => { + defaultOptions = { + sourceFiles: [ + '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + '/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts' + ], + packagePaths: [ + 'packages/ckeditor5-foo', + 'packages/ckeditor5-bar' + ], + corePackagePath: 'packages/ckeditor5-core', + ignoreUnusedCorePackageContexts: false, + validateOnly: false, + skipLicenseHeader: false + }; + + vi.mocked( getPackageContexts ).mockReturnValue( [] ); + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + vi.spyOn( process, 'exit' ).mockImplementation( () => {} ); + } ); + + it( 'should be a function', () => { + expect( synchronizeTranslations ).toBeInstanceOf( Function ); + } ); + + it( 'should load translations contexts', () => { + synchronizeTranslations( defaultOptions ); + + expect( getPackageContexts ).toHaveBeenCalledTimes( 1 ); + expect( getPackageContexts ).toHaveBeenCalledWith( { + packagePaths: [ + 'packages/ckeditor5-foo', + 'packages/ckeditor5-bar' + ], + corePackagePath: 'packages/ckeditor5-core' + } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Loading translations contexts...' ); + } ); + + it( 'should load messages from source files', () => { + synchronizeTranslations( defaultOptions ); + + expect( getSourceMessages ).toHaveBeenCalledTimes( 1 ); + expect( getSourceMessages ).toHaveBeenCalledWith( { + packagePaths: [ + 'packages/ckeditor5-foo', + 'packages/ckeditor5-bar' + ], + sourceFiles: [ + '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + '/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts' + ], + onErrorCallback: expect.any( Function ) + } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Loading messages from source files...' ); + } ); + + it( 'should collect errors when loading messages from source files failed', () => { + vi.mocked( getSourceMessages ).mockImplementation( ( { onErrorCallback } ) => { + onErrorCallback( 'Example error when loading messages from source files.' ); + + return []; + } ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( '🔥 The following errors have been found:' ); + expect( stubs.logger.error ).toHaveBeenCalledWith( ' - Example error when loading messages from source files.' ); + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should synchronize translations files', () => { + synchronizeTranslations( defaultOptions ); + + expect( synchronizeTranslationsBasedOnContext ).toHaveBeenCalledTimes( 1 ); + expect( synchronizeTranslationsBasedOnContext ).toHaveBeenCalledWith( { + packageContexts: [], + sourceMessages: [], + skipLicenseHeader: false + } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Synchronizing translations files...' ); + } ); + + it( 'should synchronize translations files with skipping the license header', () => { + defaultOptions.skipLicenseHeader = true; + + synchronizeTranslations( defaultOptions ); + + expect( synchronizeTranslationsBasedOnContext ).toHaveBeenCalledTimes( 1 ); + expect( synchronizeTranslationsBasedOnContext ).toHaveBeenCalledWith( { + packageContexts: [], + sourceMessages: [], + skipLicenseHeader: true + } ); + + expect( stubs.logger.info ).toHaveBeenCalledWith( '📍 Synchronizing translations files...' ); + } ); + + it( 'should not synchronize translations files when validation mode is enabled', () => { + defaultOptions.validateOnly = true; + synchronizeTranslations( defaultOptions ); + + expect( synchronizeTranslationsBasedOnContext ).not.toHaveBeenCalled(); + expect( stubs.logger.info ).toHaveBeenCalledWith( '✨ No errors found.' ); + } ); + + describe( 'validation', () => { + describe( 'missing context', () => { + it( 'should return no error if there is no missing context (no context, no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "foo", no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "core", no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "foo", message in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "core", message in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "core", message in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-core', + filePath: '/absolute/path/to/packages/ckeditor5-core/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return no error if there is no missing context (context in "foo" and "core", messages in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id2: 'Example message 2.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + }, + { + id: 'id2', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Missing context' ) ); + } ); + + it( 'should return error if there is missing context (no context, message in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing context "id1" in "/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is missing context (context in "foo", message in "bar")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-bar', + filePath: '/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing context "id1" in "/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is missing context (context in "foo", message in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-core', + filePath: '/absolute/path/to/packages/ckeditor5-core/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Missing context "id1" in "/absolute/path/to/packages/ckeditor5-core/src/utils/file.ts".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + + describe( 'all context used', () => { + it( 'should return no error if all context is used (no context, no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return no error if all context is used (context in "foo", message in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return no error if all context is used (context in "core", message in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return no error if all context is used (context in "core", message in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-core', + filePath: '/absolute/path/to/packages/ckeditor5-core/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return no error if all context is used (context in "foo" and "core", messages in "foo")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id2: 'Example message 2.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + }, + { + id: 'id2', + packagePath: 'packages/ckeditor5-foo', + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return no error if all context is used (context in "core", no message, ignore core)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + defaultOptions.ignoreUnusedCorePackageContexts = true; + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Unused context' ) ); + } ); + + it( 'should return error if there is unused context (context in "foo", no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Unused context "id1" in "packages/ckeditor5-foo/lang/contexts.json".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is unused context (context in "foo", message in "bar")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-bar', + filePath: '/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Unused context "id1" in "packages/ckeditor5-foo/lang/contexts.json".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is unused context (context in "foo", message in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [ + { + id: 'id1', + packagePath: 'packages/ckeditor5-core', + filePath: '/absolute/path/to/packages/ckeditor5-core/src/utils/file.ts' + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Unused context "id1" in "packages/ckeditor5-foo/lang/contexts.json".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'should return error if there is unused context (context in "core", no message)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Unused context "id1" in "packages/ckeditor5-core/lang/contexts.json".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + + describe( 'duplicated context', () => { + it( 'should return no error if there is no duplicated context (no context)', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: {} + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Duplicated context' ) ); + } ); + + it( 'should return no error if there is no duplicated context (no context in "foo", context in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Duplicated context' ) ); + } ); + + it( 'should return no error if there is no duplicated context (context in "foo", another context in "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id2: 'Example message 2.' + } + } + ] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).not.toHaveBeenCalledWith( expect.stringContaining( 'Duplicated context' ) ); + } ); + + it( 'should return error if there is duplicated context (the same context in "foo" and "core")', () => { + vi.mocked( getPackageContexts ).mockReturnValue( [ + { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + }, + { + packagePath: 'packages/ckeditor5-core', + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + contextContent: { + id1: 'Example message 1.' + } + } + ] ); + + vi.mocked( getSourceMessages ).mockReturnValue( [] ); + + synchronizeTranslations( defaultOptions ); + + expect( stubs.logger.error ).toHaveBeenCalledWith( + ' - Duplicated context "id1" in ' + + '"packages/ckeditor5-foo/lang/contexts.json", "packages/ckeditor5-core/lang/contexts.json".' + ); + + expect( process.exit ).toHaveBeenCalledWith( 1 ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/cleantranslationfilecontent.js b/packages/ckeditor5-dev-translations/tests/utils/cleantranslationfilecontent.js new file mode 100644 index 000000000..7b774d278 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/cleantranslationfilecontent.js @@ -0,0 +1,44 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import cleanTranslationFileContent from '../../lib/utils/cleantranslationfilecontent.js'; + +describe( 'cleanTranslationFileContent()', () => { + let translations; + + beforeEach( () => { + translations = { + headers: { + 'Project-Id-Version': 'Value from Project-Id-Version', + 'Report-Msgid-Bugs-To': 'Value from Report-Msgid-Bugs-To', + 'POT-Creation-Date': 'Value from POT-Creation-Date', + 'PO-Revision-Date': 'Value from PO-Revision-Date', + 'Last-Translator': 'Value from Last-Translator', + 'Language': 'Value from Language', + 'Language-Team': 'Value from Language-Team', + 'Content-Type': 'Value from Content-Type', + 'Content-Transfer-Encoding': 'Value from Content-Transfer-Encoding', + 'Plural-Forms': 'Value from Plural-Forms' + } + }; + } ); + + it( 'should be a function', () => { + expect( cleanTranslationFileContent ).toBeInstanceOf( Function ); + } ); + + it( 'should return translation file without unneeded headers', () => { + const result = cleanTranslationFileContent( translations ); + + expect( result ).toEqual( { + headers: { + 'Language': 'Value from Language', + 'Content-Type': 'text/plain; charset=UTF-8', + 'Plural-Forms': 'Value from Plural-Forms' + } + } ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/constants.js b/packages/ckeditor5-dev-translations/tests/utils/constants.js new file mode 100644 index 000000000..48a43457f --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/constants.js @@ -0,0 +1,17 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, expect, it } from 'vitest'; +import * as constants from '../../lib/utils/constants.js'; + +describe( 'constants', () => { + it( '#CONTEXT_FILE_PATH', () => { + expect( constants.CONTEXT_FILE_PATH ).toBeTypeOf( 'string' ); + } ); + + it( '#TRANSLATION_FILES_PATH', () => { + expect( constants.TRANSLATION_FILES_PATH ).toBeTypeOf( 'string' ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/createmissingpackagetranslations.js b/packages/ckeditor5-dev-translations/tests/utils/createmissingpackagetranslations.js new file mode 100644 index 000000000..1524cd9b0 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/createmissingpackagetranslations.js @@ -0,0 +1,117 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import PO from 'pofile'; +import { getNPlurals, getFormula } from 'plural-forms'; +import cleanTranslationFileContent from '../../lib/utils/cleantranslationfilecontent.js'; +import getLanguages from '../../lib/utils/getlanguages.js'; +import createMissingPackageTranslations from '../../lib/utils/createmissingpackagetranslations.js'; + +vi.mock( 'fs-extra' ); +vi.mock( 'pofile' ); +vi.mock( 'plural-forms' ); +vi.mock( '../../lib/utils/cleantranslationfilecontent.js' ); +vi.mock( '../../lib/utils/getlanguages.js' ); + +describe( 'createMissingPackageTranslations()', () => { + let translations, defaultOptions; + + beforeEach( () => { + translations = { + headers: {} + }; + + defaultOptions = { + packagePath: 'packages/ckeditor5-foo', + skipLicenseHeader: false + }; + + vi.mocked( PO.parse ).mockReturnValue( translations ); + + vi.mocked( getNPlurals ).mockReturnValue( 4 ); + vi.mocked( getFormula ).mockReturnValue( 'example plural formula' ); + + vi.mocked( getLanguages ).mockReturnValue( [ + { localeCode: 'en', languageCode: 'en', languageFileName: 'en' }, + { localeCode: 'zh_TW', languageCode: 'zh', languageFileName: 'zh-tw' } + ] ); + + vi.mocked( cleanTranslationFileContent ).mockReturnValue( { + toString: () => 'Clean PO file content.' + } ); + + vi.mocked( fs.existsSync ).mockImplementation( path => { + if ( path === 'packages/ckeditor5-foo/lang/translations/en.po' ) { + return true; + } + + return false; + } ); + + vi.mocked( fs.readFileSync ).mockReturnValue( [ + `# Copyright (c) 2003-${ new Date().getFullYear() }, CKSource Holding sp. z o.o. All rights reserved.`, + '', + '# Example translation file header.', + '' + ].join( '\n' ) ); + } ); + + it( 'should be a function', () => { + expect( createMissingPackageTranslations ).toBeInstanceOf( Function ); + } ); + + it( 'should check if translation files exist for each language', () => { + createMissingPackageTranslations( defaultOptions ); + + expect( fs.existsSync ).toHaveBeenCalledTimes( 2 ); + expect( fs.existsSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/en.po' ); + expect( fs.existsSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/zh-tw.po' ); + } ); + + it( 'should create missing translation files from the template', () => { + createMissingPackageTranslations( defaultOptions ); + + expect( fs.readFileSync ).toHaveBeenCalledTimes( 1 ); + expect( fs.readFileSync ).toHaveBeenCalledWith( + expect.stringMatching( 'ckeditor5-dev-translations/lib/templates/translation.po' ), + 'utf-8' + ); + + expect( getNPlurals ).toHaveBeenCalledWith( 'zh' ); + expect( getFormula ).toHaveBeenCalledWith( 'zh' ); + + expect( translations.headers.Language ).toEqual( 'zh_TW' ); + expect( translations.headers[ 'Plural-Forms' ] ).toEqual( 'nplurals=4; plural=example plural formula;' ); + } ); + + it( 'should not read the template if `skipLicenseHeader` flag is set', () => { + defaultOptions.skipLicenseHeader = true; + + createMissingPackageTranslations( defaultOptions ); + + expect( fs.readFileSync ).not.toHaveBeenCalled(); + + expect( getNPlurals ).toHaveBeenCalledWith( 'zh' ); + expect( getFormula ).toHaveBeenCalledWith( 'zh' ); + + expect( translations.headers.Language ).toEqual( 'zh_TW' ); + expect( translations.headers[ 'Plural-Forms' ] ).toEqual( 'nplurals=4; plural=example plural formula;' ); + } ); + + it( 'should save missing translation files on filesystem after cleaning the content', () => { + createMissingPackageTranslations( defaultOptions ); + + expect( cleanTranslationFileContent ).toHaveBeenCalledTimes( 1 ); + + expect( fs.outputFileSync ).toHaveBeenCalledTimes( 1 ); + expect( fs.outputFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-foo/lang/translations/zh-tw.po', + 'Clean PO file content.', + 'utf-8' + ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/getlanguages.js b/packages/ckeditor5-dev-translations/tests/utils/getlanguages.js new file mode 100644 index 000000000..2327f8206 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/getlanguages.js @@ -0,0 +1,57 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { describe, expect, it } from 'vitest'; +import getLanguages from '../../lib/utils/getlanguages.js'; + +describe( 'getLanguages()', () => { + it( 'should be a function', () => { + expect( getLanguages ).toBeInstanceOf( Function ); + } ); + + it( 'should return an array of languages', () => { + const languages = getLanguages(); + + expect( languages ).toBeInstanceOf( Array ); + expect( languages[ 0 ] ).toEqual( expect.objectContaining( { + localeCode: expect.any( String ), + languageCode: expect.any( String ), + languageFileName: expect.any( String ) + } ) ); + } ); + + it( 'should return Polish language', () => { + const languages = getLanguages(); + const languagePolish = languages.find( item => item.localeCode === 'pl' ); + + expect( languagePolish ).toEqual( { + localeCode: 'pl', + languageCode: 'pl', + languageFileName: 'pl' + } ); + } ); + + it( 'should normalize language if it contains special characters', () => { + const languages = getLanguages(); + const languageSerbianLatin = languages.find( l => l.localeCode === 'sr@latin' ); + + expect( languageSerbianLatin ).toEqual( { + localeCode: 'sr@latin', + languageCode: 'sr', + languageFileName: 'sr-latn' + } ); + } ); + + it( 'should use predefined filename if defined', () => { + const languages = getLanguages(); + const languageChineseTaiwan = languages.find( l => l.localeCode === 'zh_TW' ); + + expect( languageChineseTaiwan ).toEqual( { + localeCode: 'zh_TW', + languageCode: 'zh', + languageFileName: 'zh' + } ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/getpackagecontext.js b/packages/ckeditor5-dev-translations/tests/utils/getpackagecontext.js new file mode 100644 index 000000000..1e0465ae9 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/getpackagecontext.js @@ -0,0 +1,65 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import getPackageContext from '../../lib/utils/getpackagecontext.js'; + +vi.mock( 'fs-extra' ); + +describe( 'getPackageContext()', () => { + let defaultOptions; + + beforeEach( () => { + defaultOptions = { + packagePath: 'packages/ckeditor5-foo' + }; + + vi.mocked( fs.readJsonSync ).mockImplementation( path => { + if ( path === 'packages/ckeditor5-foo/lang/contexts.json' ) { + return { + id1: 'Context for message id1 from "ckeditor5-foo".' + }; + } + + return null; + } ); + } ); + + it( 'should be a function', () => { + expect( getPackageContext ).toBeInstanceOf( Function ); + } ); + + it( 'should read context file from package', () => { + getPackageContext( defaultOptions ); + + expect( fs.readJsonSync ).toHaveBeenCalledTimes( 1 ); + expect( fs.readJsonSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/contexts.json', { throws: false } ); + } ); + + it( 'should return package contexts', () => { + const result = getPackageContext( defaultOptions ); + + expect( result ).toEqual( expect.objectContaining( { + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".' + }, + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + packagePath: 'packages/ckeditor5-foo' + } ) ); + } ); + + it( 'should return empty context if package does not have context file', () => { + defaultOptions.packagePath = 'packages/ckeditor5-bar'; + + const result = getPackageContext( defaultOptions ); + + expect( result ).toEqual( expect.objectContaining( { + contextContent: {}, + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + packagePath: 'packages/ckeditor5-bar' + } ) ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/getpackagecontexts.js b/packages/ckeditor5-dev-translations/tests/utils/getpackagecontexts.js new file mode 100644 index 000000000..471860d78 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/getpackagecontexts.js @@ -0,0 +1,104 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import getPackageContext from '../../lib/utils/getpackagecontext.js'; +import getPackageContexts from '../../lib/utils/getpackagecontexts.js'; + +vi.mock( '../../lib/utils/getpackagecontext.js' ); + +describe( 'getPackageContexts()', () => { + let defaultOptions; + + beforeEach( () => { + defaultOptions = { + packagePaths: [ 'packages/ckeditor5-foo' ], + corePackagePath: 'packages/ckeditor5-core' + }; + + vi.mocked( getPackageContext ).mockImplementation( ( { packagePath } ) => { + const contextContent = {}; + + if ( packagePath === 'packages/ckeditor5-foo' ) { + contextContent.id1 = 'Context for message id1 from "ckeditor5-foo".'; + } + + if ( packagePath === 'packages/ckeditor5-core' ) { + contextContent.id2 = 'Context for message id2 from "ckeditor5-core".'; + } + + return { + contextContent, + contextFilePath: packagePath + '/lang/contexts.json', + packagePath + }; + } ); + } ); + + it( 'should be a function', () => { + expect( getPackageContexts ).toBeInstanceOf( Function ); + } ); + + it( 'should add core package if it is not included in the packages', () => { + getPackageContexts( defaultOptions ); + + expect( defaultOptions.packagePaths ).toEqual( [ + 'packages/ckeditor5-foo', + 'packages/ckeditor5-core' + ] ); + } ); + + it( 'should not duplicate core package if it is already included in the packages', () => { + defaultOptions.packagePaths.push( 'packages/ckeditor5-core' ); + + getPackageContexts( defaultOptions ); + + expect( defaultOptions.packagePaths ).toEqual( [ + 'packages/ckeditor5-foo', + 'packages/ckeditor5-core' + ] ); + } ); + + it( 'should return package contexts', () => { + const result = getPackageContexts( defaultOptions ); + + expect( result ).toBeInstanceOf( Array ); + expect( result ).toHaveLength( 2 ); + expect( result ).toEqual( expect.arrayContaining( [ + { + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".' + }, + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + packagePath: 'packages/ckeditor5-foo' + } + ] ) ); + expect( result ).toEqual( expect.arrayContaining( [ + { + contextContent: { + id2: 'Context for message id2 from "ckeditor5-core".' + }, + contextFilePath: 'packages/ckeditor5-core/lang/contexts.json', + packagePath: 'packages/ckeditor5-core' + } + ] ) ); + } ); + + it( 'should return empty context if package does not have context file', () => { + defaultOptions.packagePaths.push( 'packages/ckeditor5-bar' ); + + const result = getPackageContexts( defaultOptions ); + + expect( result ).toBeInstanceOf( Array ); + expect( result ).toHaveLength( 3 ); + expect( result ).toEqual( expect.arrayContaining( [ + { + contextContent: {}, + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + packagePath: 'packages/ckeditor5-bar' + } + ] ) ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/getsourcemessages.js b/packages/ckeditor5-dev-translations/tests/utils/getsourcemessages.js new file mode 100644 index 000000000..58ec47070 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/getsourcemessages.js @@ -0,0 +1,102 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import findMessages from '../../lib/findmessages.js'; +import getSourceMessages from '../../lib/utils/getsourcemessages.js'; + +vi.mock( 'fs-extra' ); +vi.mock( '../../lib/findmessages.js' ); + +describe( 'getSourceMessages()', () => { + let defaultOptions; + + beforeEach( () => { + defaultOptions = { + packagePaths: [ 'packages/ckeditor5-foo' ], + sourceFiles: [ + '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + '/absolute/path/to/packages/ckeditor5-bar/src/utils/file.ts' + ], + onErrorCallback: vi.fn() + }; + + vi.mocked( fs.readFileSync ).mockImplementation( path => { + if ( path === '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts' ) { + return 'Content from file.ts.'; + } + + throw new Error( `ENOENT: no such file or directory, open ${ path }` ); + } ); + } ); + + it( 'should be a function', () => { + expect( getSourceMessages ).toBeInstanceOf( Function ); + } ); + + it( 'should read source files only from provided packages', () => { + getSourceMessages( defaultOptions ); + + expect( fs.readFileSync ).toHaveBeenCalledTimes( 1 ); + expect( fs.readFileSync ).toHaveBeenCalledWith( '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', 'utf-8' ); + } ); + + it( 'should find messages from source files', () => { + getSourceMessages( defaultOptions ); + + expect( findMessages ).toHaveBeenCalledTimes( 1 ); + expect( findMessages ).toHaveBeenCalledWith( + 'Content from file.ts.', + '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + expect.any( Function ), + defaultOptions.onErrorCallback + ); + } ); + + it( 'should return found messages from source files', () => { + vi.mocked( findMessages ).mockImplementation( ( fileContent, filePath, onMessageCallback ) => { + onMessageCallback( { id: 'id1', string: 'Example message 1.' } ); + onMessageCallback( { id: 'id2', string: 'Example message 2.' } ); + } ); + + const result = getSourceMessages( defaultOptions ); + + expect( result ).toEqual( [ + { + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + packagePath: 'packages/ckeditor5-foo', + id: 'id1', + string: 'Example message 1.' + }, + { + filePath: '/absolute/path/to/packages/ckeditor5-foo/src/utils/file.ts', + packagePath: 'packages/ckeditor5-foo', + id: 'id2', + string: 'Example message 2.' + } + ] ); + } ); + + it( 'should not find messages if package paths do not match exactly the file path', () => { + defaultOptions.sourceFiles = [ + '/absolute/path/to/packages/ckeditor5-foo-bar/src/utils/file.ts' + ]; + + getSourceMessages( defaultOptions ); + + expect( findMessages ).not.toHaveBeenCalled(); + } ); + + it( 'should call error callback in case of an error', () => { + vi.mocked( findMessages ).mockImplementation( ( fileContent, filePath, onMessageCallback, onErrorCallback ) => { + onErrorCallback( 'Example problem has been detected.' ); + } ); + + getSourceMessages( defaultOptions ); + + expect( defaultOptions.onErrorCallback ).toHaveBeenCalledWith( 'Example problem has been detected.' ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/movetranslationsbetweenpackages.js b/packages/ckeditor5-dev-translations/tests/utils/movetranslationsbetweenpackages.js new file mode 100644 index 000000000..4f40ebde9 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/movetranslationsbetweenpackages.js @@ -0,0 +1,332 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import PO from 'pofile'; +import { glob } from 'glob'; +import cleanTranslationFileContent from '../../lib/utils/cleantranslationfilecontent.js'; +import moveTranslationsBetweenPackages from '../../lib/utils/movetranslationsbetweenpackages.js'; + +vi.mock( 'fs-extra' ); +vi.mock( 'pofile' ); +vi.mock( 'glob' ); +vi.mock( '../../lib/utils/cleantranslationfilecontent.js' ); + +describe( 'moveTranslationsBetweenPackages()', () => { + let defaultOptions, packageTranslationsFoo, packageTranslationsBar, packageContextFoo, packageContextBar; + + beforeEach( () => { + packageTranslationsFoo = [ + [ 'id1', 'Context for message id1 from "ckeditor5-foo".' ] + ]; + + packageTranslationsBar = [ + [ 'id2', 'Context for message id2 from "ckeditor5-bar".' ] + ]; + + packageContextFoo = { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: Object.fromEntries( packageTranslationsFoo ) + }; + + packageContextBar = { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: Object.fromEntries( packageTranslationsBar ) + }; + + defaultOptions = { + packageContexts: [ packageContextFoo, packageContextBar ], + config: [ + { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-bar', + messageId: 'id1' + } + ] + }; + + vi.mocked( fs.existsSync ).mockReturnValue( true ); + + vi.mocked( fs.readFileSync ).mockImplementation( path => { + if ( path.startsWith( 'packages/ckeditor5-foo/lang/translations/' ) ) { + return JSON.stringify( { + items: packageTranslationsFoo.map( ( [ msgid, msgctxt ] ) => ( { msgid, msgctxt } ) ) + } ); + } + + if ( path.startsWith( 'packages/ckeditor5-bar/lang/translations/' ) ) { + return JSON.stringify( { + items: packageTranslationsBar.map( ( [ msgid, msgctxt ] ) => ( { msgid, msgctxt } ) ) + } ); + } + + return JSON.stringify( {} ); + } ); + + vi.mocked( PO.parse ).mockImplementation( data => JSON.parse( data ) ); + + vi.mocked( glob.sync ).mockImplementation( pattern => [ + pattern.replace( '*', 'en' ), + pattern.replace( '*', 'pl' ) + ] ); + + vi.mocked( cleanTranslationFileContent ).mockReturnValue( { + toString: () => 'Clean PO file content.' + } ); + } ); + + it( 'should be a function', () => { + expect( moveTranslationsBetweenPackages ).toBeInstanceOf( Function ); + } ); + + it( 'should not move translations between packages if source and destination are the same', () => { + defaultOptions.config = [ { + source: 'packages/ckeditor5-foo', + destination: 'packages/ckeditor5-foo', + messageId: 'id1' + } ]; + + moveTranslationsBetweenPackages( defaultOptions ); + + expect( packageContextFoo ).toEqual( { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".' + } + } ); + + expect( packageContextBar ).toEqual( { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: { + id2: 'Context for message id2 from "ckeditor5-bar".' + } + } ); + + expect( fs.outputJsonSync ).not.toHaveBeenCalled(); + expect( fs.outputFileSync ).not.toHaveBeenCalled(); + } ); + + it( 'should move translation context between packages', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + expect( packageContextFoo ).toEqual( { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + } ); + + expect( packageContextBar ).toEqual( { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".', + id2: 'Context for message id2 from "ckeditor5-bar".' + } + } ); + } ); + + it( 'should overwrite existing translation context in destination package', () => { + packageContextBar.contextContent.id1 = 'Context for message id1 from "ckeditor5-bar".'; + + moveTranslationsBetweenPackages( defaultOptions ); + + expect( packageContextFoo ).toEqual( { + packagePath: 'packages/ckeditor5-foo', + contextFilePath: 'packages/ckeditor5-foo/lang/contexts.json', + contextContent: {} + } ); + + expect( packageContextBar ).toEqual( { + packagePath: 'packages/ckeditor5-bar', + contextFilePath: 'packages/ckeditor5-bar/lang/contexts.json', + contextContent: { + id1: 'Context for message id1 from "ckeditor5-foo".', + id2: 'Context for message id2 from "ckeditor5-bar".' + } + } ); + } ); + + it( 'should save translation contexts on filesystem', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + expect( fs.outputJsonSync ).toHaveBeenCalledTimes( 2 ); + expect( fs.outputJsonSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-foo/lang/contexts.json', + {}, + { spaces: '\t' } + ); + + expect( fs.outputJsonSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-bar/lang/contexts.json', + { + id1: 'Context for message id1 from "ckeditor5-foo".', + id2: 'Context for message id2 from "ckeditor5-bar".' + }, + { spaces: '\t' } + ); + } ); + + it( 'should search for source translation files', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + expect( glob.sync ).toHaveBeenCalledTimes( 1 ); + expect( glob.sync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/*.po' ); + } ); + + it( 'should parse each translation file', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + expect( fs.readFileSync ).toHaveBeenCalledTimes( 4 ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/en.po', 'utf-8' ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/pl.po', 'utf-8' ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-bar/lang/translations/en.po', 'utf-8' ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-bar/lang/translations/pl.po', 'utf-8' ); + + expect( PO.parse ).toHaveBeenCalledTimes( 4 ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 1, + '{"items":[{"msgid":"id1","msgctxt":"Context for message id1 from \\"ckeditor5-foo\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 2, + '{"items":[{"msgid":"id2","msgctxt":"Context for message id2 from \\"ckeditor5-bar\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 3, + '{"items":[{"msgid":"id1","msgctxt":"Context for message id1 from \\"ckeditor5-foo\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 4, + '{"items":[{"msgid":"id2","msgctxt":"Context for message id2 from \\"ckeditor5-bar\\"."}]}' + ); + } ); + + it( 'should move translations between packages for each language', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + const [ + sourceTranslationsFooEn, + sourceTranslationsBarEn, + sourceTranslationsFooPl, + sourceTranslationsBarPl + ] = PO.parse.mock.results.map( entry => entry.value ); + + expect( sourceTranslationsFooEn.items ).toEqual( [] ); + expect( sourceTranslationsBarEn.items ).toEqual( [ + { msgid: 'id2', msgctxt: 'Context for message id2 from "ckeditor5-bar".' }, + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + + expect( sourceTranslationsFooPl.items ).toEqual( [] ); + expect( sourceTranslationsBarPl.items ).toEqual( [ + { msgid: 'id2', msgctxt: 'Context for message id2 from "ckeditor5-bar".' }, + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + } ); + + it( 'should overwrite existing translations in destination package', () => { + packageTranslationsBar.push( + [ 'id1', 'Context for message id1 from "ckeditor5-bar".' ] + ); + + moveTranslationsBetweenPackages( defaultOptions ); + + const [ + sourceTranslationsFooEn, + sourceTranslationsBarEn, + sourceTranslationsFooPl, + sourceTranslationsBarPl + ] = PO.parse.mock.results.map( entry => entry.value ); + + expect( sourceTranslationsFooEn.items ).toEqual( [] ); + expect( sourceTranslationsBarEn.items ).toEqual( [ + { msgid: 'id2', msgctxt: 'Context for message id2 from "ckeditor5-bar".' }, + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + + expect( sourceTranslationsFooPl.items ).toEqual( [] ); + expect( sourceTranslationsBarPl.items ).toEqual( [ + { msgid: 'id2', msgctxt: 'Context for message id2 from "ckeditor5-bar".' }, + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + } ); + + it( 'should use the source translation file as a base if the destination file does not exist', () => { + vi.mocked( fs.existsSync ).mockImplementation( path => { + return path !== 'packages/ckeditor5-bar/lang/translations/pl.po'; + } ); + + moveTranslationsBetweenPackages( defaultOptions ); + + expect( PO.parse ).toHaveBeenCalledTimes( 4 ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 1, + '{"items":[{"msgid":"id1","msgctxt":"Context for message id1 from \\"ckeditor5-foo\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 2, + '{"items":[{"msgid":"id2","msgctxt":"Context for message id2 from \\"ckeditor5-bar\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 3, + '{"items":[{"msgid":"id1","msgctxt":"Context for message id1 from \\"ckeditor5-foo\\"."}]}' + ); + expect( PO.parse ).toHaveBeenNthCalledWith( + 4, + '{"items":[{"msgid":"id1","msgctxt":"Context for message id1 from \\"ckeditor5-foo\\"."}]}' + ); + + const [ + sourceTranslationsFooEn, + sourceTranslationsBarEn, + sourceTranslationsFooPl, + sourceTranslationsBarPl + ] = PO.parse.mock.results.map( entry => entry.value ); + + expect( sourceTranslationsFooEn.items ).toEqual( [] ); + expect( sourceTranslationsBarEn.items ).toEqual( [ + { msgid: 'id2', msgctxt: 'Context for message id2 from "ckeditor5-bar".' }, + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + + expect( sourceTranslationsFooPl.items ).toEqual( [] ); + expect( sourceTranslationsBarPl.items ).toEqual( [ + { msgid: 'id1', msgctxt: 'Context for message id1 from "ckeditor5-foo".' } + ] ); + } ); + + it( 'should save updated translation files on filesystem after cleaning the content', () => { + moveTranslationsBetweenPackages( defaultOptions ); + + expect( cleanTranslationFileContent ).toHaveBeenCalledTimes( 4 ); + + expect( fs.outputFileSync ).toHaveBeenCalledTimes( 4 ); + expect( fs.outputFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-foo/lang/translations/en.po', + 'Clean PO file content.', + 'utf-8' + ); + expect( fs.outputFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-foo/lang/translations/pl.po', + 'Clean PO file content.', + 'utf-8' + ); + expect( fs.outputFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-bar/lang/translations/en.po', + 'Clean PO file content.', + 'utf-8' + ); + expect( fs.outputFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-bar/lang/translations/pl.po', + 'Clean PO file content.', + 'utf-8' + ); + } ); +} ); diff --git a/packages/ckeditor5-dev-translations/tests/utils/synchronizetranslationsbasedoncontext.js b/packages/ckeditor5-dev-translations/tests/utils/synchronizetranslationsbasedoncontext.js new file mode 100644 index 000000000..58d4e1fd5 --- /dev/null +++ b/packages/ckeditor5-dev-translations/tests/utils/synchronizetranslationsbasedoncontext.js @@ -0,0 +1,199 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import PO from 'pofile'; +import { glob } from 'glob'; +import cleanTranslationFileContent from '../../lib/utils/cleantranslationfilecontent.js'; +import createMissingPackageTranslations from '../../lib/utils/createmissingpackagetranslations.js'; +import synchronizeTranslationsBasedOnContext from '../../lib/utils/synchronizetranslationsbasedoncontext.js'; + +vi.mock( 'fs-extra' ); +vi.mock( 'pofile' ); +vi.mock( 'glob' ); +vi.mock( '../../lib/utils/createmissingpackagetranslations.js' ); +vi.mock( '../../lib/utils/cleantranslationfilecontent.js' ); + +describe( 'synchronizeTranslationsBasedOnContext()', () => { + let defaultOptions, translations, stubs; + + beforeEach( () => { + defaultOptions = { + packageContexts: [ + { + packagePath: 'packages/ckeditor5-foo', + contextContent: { + id1: 'Context for example message 1', + id2: 'Context for example message 2' + } + } + ], + sourceMessages: [ + { + id: 'id1', + string: 'Example message 1' + }, + { + id: 'id2', + string: 'Example message 2', + plural: 'Example message 2 - plural form' + } + ], + skipLicenseHeader: false + }; + + translations = { + headers: {}, + items: [ + { msgid: 'id1' }, + { msgid: 'id2' } + ], + toString: () => 'Raw PO file content.' + }; + + stubs = { + poItemConstructor: vi.fn() + }; + + vi.mocked( PO.parse ).mockReturnValue( translations ); + + vi.mocked( PO.parsePluralForms ).mockReturnValue( { nplurals: 4 } ); + + vi.mocked( PO.Item ).mockImplementation( () => new class { + constructor( ...args ) { + stubs.poItemConstructor( ...args ); + + this.msgid = ''; + this.msgctxt = ''; + this.msgstr = []; + this.msgid_plural = ''; + } + }() ); + + vi.mocked( glob.sync ).mockImplementation( pattern => [ pattern.replace( '*', 'en' ) ] ); + + vi.mocked( fs.readFileSync ).mockReturnValue( 'Raw PO file content.' ); + + vi.mocked( cleanTranslationFileContent ).mockReturnValue( { + toString: () => 'Clean PO file content.' + } ); + } ); + + it( 'should be a function', () => { + expect( synchronizeTranslationsBasedOnContext ).toBeInstanceOf( Function ); + } ); + + it( 'should create missing translations', () => { + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( createMissingPackageTranslations ).toHaveBeenCalledTimes( 1 ); + expect( createMissingPackageTranslations ).toHaveBeenCalledWith( { + packagePath: 'packages/ckeditor5-foo', + skipLicenseHeader: false + } ); + } ); + + it( 'should create missing translations with skipping the license header', () => { + defaultOptions.skipLicenseHeader = true; + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( createMissingPackageTranslations ).toHaveBeenCalledTimes( 1 ); + expect( createMissingPackageTranslations ).toHaveBeenCalledWith( { + packagePath: 'packages/ckeditor5-foo', + skipLicenseHeader: true + } ); + } ); + + it( 'should not update any files when package does not contain translation context', () => { + defaultOptions.packageContexts = [ + { + packagePath: 'packages/ckeditor5-foo', + contextContent: {} + } + ]; + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( createMissingPackageTranslations ).not.toHaveBeenCalled(); + expect( fs.writeFileSync ).not.toHaveBeenCalled(); + } ); + + it( 'should search for translation files', () => { + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( glob.sync ).toHaveBeenCalledTimes( 1 ); + expect( glob.sync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/*.po' ); + } ); + + it( 'should parse each translation file', () => { + vi.mocked( glob.sync ).mockImplementation( pattern => { + return [ 'en', 'pl' ].map( language => pattern.replace( '*', language ) ); + } ); + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( fs.readFileSync ).toHaveBeenCalledTimes( 2 ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/en.po', 'utf-8' ); + expect( fs.readFileSync ).toHaveBeenCalledWith( 'packages/ckeditor5-foo/lang/translations/pl.po', 'utf-8' ); + } ); + + it( 'should remove unused translations', () => { + translations.items.push( + { msgid: 'id3' }, + { msgid: 'id4' } + ); + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( translations.items ).toEqual( [ + { msgid: 'id1' }, + { msgid: 'id2' } + ] ); + } ); + + it( 'should add missing translations', () => { + translations.items = []; + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( translations.items ).toEqual( [ + { + msgid: 'id1', + msgctxt: 'Context for example message 1', + msgid_plural: '', + msgstr: [ '' ] + }, + { + msgid: 'id2', + msgctxt: 'Context for example message 2', + msgid_plural: 'Example message 2 - plural form', + msgstr: [ '', '', '', '' ] + } + ] ); + } ); + + it( 'should save updated translation files on filesystem after cleaning the content', () => { + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( cleanTranslationFileContent ).toHaveBeenCalledTimes( 1 ); + + expect( fs.writeFileSync ).toHaveBeenCalledTimes( 1 ); + expect( fs.writeFileSync ).toHaveBeenCalledWith( + 'packages/ckeditor5-foo/lang/translations/en.po', + 'Clean PO file content.', + 'utf-8' + ); + } ); + + it( 'should not save translation files on filesystem if their content is not updated', () => { + vi.mocked( cleanTranslationFileContent ).mockImplementation( input => input ); + + synchronizeTranslationsBasedOnContext( defaultOptions ); + + expect( fs.writeFileSync ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/scripts/publishpackages.js b/scripts/publishpackages.js index dc3af8af9..c4d2af3be 100644 --- a/scripts/publishpackages.js +++ b/scripts/publishpackages.js @@ -10,6 +10,7 @@ import { Listr } from 'listr2'; import { ListrInquirerPromptAdapter } from '@listr2/prompt-adapter-inquirer'; import { confirm } from '@inquirer/prompts'; +import { Octokit } from '@octokit/rest'; import * as releaseTools from '@ckeditor/ckeditor5-dev-release-tools'; import parseArguments from './utils/parsearguments.js'; import getListrOptions from './utils/getlistroptions.js'; @@ -19,7 +20,7 @@ const cliArguments = parseArguments( process.argv.slice( 2 ) ); const latestVersion = releaseTools.getLastFromChangelog(); const versionChangelog = releaseTools.getChangesForVersion( latestVersion ); -let githubToken; +const githubToken = await getGitHubToken(); if ( !cliArguments.npmTag ) { cliArguments.npmTag = releaseTools.getNpmTagFromVersion( latestVersion ); @@ -68,21 +69,39 @@ const tasks = new Listr( [ options: { persistentOutput: true } - } -], getListrOptions( cliArguments ) ); + }, + { + title: 'Mark v43.0.0 as "latest" (GitHub)', + task: async () => { + const github = new Octokit( { + version: '3.0.0', + auth: `token ${ githubToken }` + } ); -( async () => { - try { - if ( process.env.CKE5_RELEASE_TOKEN ) { - githubToken = process.env.CKE5_RELEASE_TOKEN; - } else { - githubToken = await releaseTools.provideToken(); + return github.request( 'PATCH /repos/{owner}/{repo}/releases/{release_id}', { + owner: 'ckeditor', + repo: 'ckeditor5-dev', + release_id: 174058828, // v43.0.0 + make_latest: true + } ); } + } +], getListrOptions( cliArguments ) ); - await tasks.run(); - } catch ( err ) { +tasks.run() + .catch( err => { process.exitCode = 1; console.error( err ); + } ); + +/** + * @returns {Promise.} + */ +async function getGitHubToken() { + if ( process.env.CKE5_RELEASE_TOKEN ) { + return process.env.CKE5_RELEASE_TOKEN; } -} )(); + + return releaseTools.provideToken(); +}