From 544aec08b90879d9d54c2eb5b16a4f742db5d451 Mon Sep 17 00:00:00 2001 From: icodesign Date: Sat, 31 Aug 2024 10:21:55 +0800 Subject: [PATCH] Add support for exporting and importing from xcode project --- apps/cli/src/commands/core.ts | 44 ++++++++++++---- apps/cli/src/commands/localize.ts | 2 +- packages/ioloc/src/export/index.ts | 81 +++++++++++++++++++++++++++-- packages/ioloc/src/import/index.ts | 81 ++++++++++++++++++++++++++++- packages/ioloc/src/index.ts | 67 +++++++++++++++--------- packages/ioloc/src/xcode.ts | 83 +++++++++++++----------------- packages/ioloc/src/xliff/index.ts | 24 ++++++++- packages/ioloc/tests/e2e.test.ts | 72 ++++++++++++++------------ packages/translate/src/index.ts | 16 +++++- 9 files changed, 346 insertions(+), 124 deletions(-) diff --git a/apps/cli/src/commands/core.ts b/apps/cli/src/commands/core.ts index 791bc96..1dd8e9d 100644 --- a/apps/cli/src/commands/core.ts +++ b/apps/cli/src/commands/core.ts @@ -1,9 +1,9 @@ -import { Config, parseConfig } from '@repo/base/config'; +import { Config, LocalizationFormat, parseConfig } from '@repo/base/config'; import { consoleLogger, logger } from '@repo/base/logger'; import spinner from '@repo/base/spinner'; import { createTemporaryOutputFolder, replaceBundle } from '@repo/ioloc'; import { - ExportLocalizationsResult, + LocalizationBundlePath, exportLocalizationBundle, importLocalizationBundle, textHash, @@ -38,9 +38,14 @@ export async function loadConfig({ path }: { path?: string }) { export async function exportLocalizations(config: Config) { var startTime = performance.now(); - spinner.update('Exporting localizations').start(); + const message = + config.localizations.filter((x) => x.format === LocalizationFormat.XCODE) + .length > 0 + ? `Exporting localizations (Xcode project may take a while)` + : 'Exporting localizations'; + spinner.update(message).start(); const baseFolder = path.dirname(config.path); - var exportedResults: ExportLocalizationsResult[] = []; + var exportedResults: LocalizationBundlePath[] = []; let baseOutputFolder = config.exportFolder || '.dolphin'; if (!path.isAbsolute(baseOutputFolder)) { baseOutputFolder = path.join(baseFolder, baseOutputFolder); @@ -81,7 +86,12 @@ export async function exportLocalizations(config: Config) { .map((result) => result.bundlePath) .join(', ')}`, ); - return baseOutputFolder; + return { + baseOutputFolder, + intermediateBundlePaths: exportedResults.map( + (x) => x.intermediateBundlePath, + ), + }; } export async function translateLocalizations({ @@ -104,7 +114,7 @@ export async function translateLocalizations({ const translated = translationResult.mergedStrings; const translatedCount = Object.keys(translated).length; if (translatedCount === 0) { - spinner.succeed(chalk.green('No string needs to be translated')); + spinner.succeed(chalk.green('No string needs to be translated\n')); return; } const duration = formattedDuration(performance.now() - startTime); @@ -128,20 +138,34 @@ export async function importLocalizations({ translationBundle, }: { config: Config; - translationBundle: string; + translationBundle: { + baseOutputFolder: string; + intermediateBundlePaths: (string | undefined)[]; + }; }) { const startTime = performance.now(); - spinner.next('Merging translations').start(); + const containsXcodeFormat = + config.localizations.filter((x) => x.format === LocalizationFormat.XCODE) + .length > 0; + let message = 'Merging translations'; + if (containsXcodeFormat) { + message += ' (Xcode project may take a while)'; + } + spinner.next(message).start(); logger.info(`Merging localization bundles...`); for (var index = 0; index < config.localizations.length; index++) { const localizationConfig = config.localizations[index]; const bundlePath = path.join( - translationBundle, + translationBundle.baseOutputFolder, localizationFolder(localizationConfig.id), ); await importLocalizationBundle({ config: localizationConfig, - localizationBundlePath: bundlePath, + localizationBundlePath: { + bundlePath, + intermediateBundlePath: + translationBundle.intermediateBundlePaths[index], + }, baseLanguage: config.baseLanguage, baseFolder: path.dirname(config.path), }); diff --git a/apps/cli/src/commands/localize.ts b/apps/cli/src/commands/localize.ts index 19747f5..2bf7e10 100644 --- a/apps/cli/src/commands/localize.ts +++ b/apps/cli/src/commands/localize.ts @@ -63,7 +63,7 @@ async function handleLocalizeCommand(args: CmdArgs) { }); const translationBundle = await exportLocalizations(config); await translateLocalizations({ - baseOutputFolder: translationBundle, + baseOutputFolder: translationBundle.baseOutputFolder, config, }); await importLocalizations({ diff --git a/packages/ioloc/src/export/index.ts b/packages/ioloc/src/export/index.ts index a2ba274..0780b2c 100644 --- a/packages/ioloc/src/export/index.ts +++ b/packages/ioloc/src/export/index.ts @@ -2,10 +2,16 @@ import { logger } from '@repo/base/logger'; import fs from 'node:fs'; import path from 'node:path'; -import { ExportLocalizations, ExportLocalizationsResult } from '../index.js'; -import { createOutputFolderIfNeed } from '../utils.js'; +import { ExportLocalizations, LocalizationBundlePath } from '../index.js'; +import { + createOutputFolderIfNeed, + createTemporaryOutputFolder, +} from '../utils.js'; +import { XcodeExportLocalizations } from '../xcode.js'; import { stringifyXliff2 } from '../xliff/index.js'; import { Xliff } from '../xliff/xliff-spec.js'; +import { XliffParser } from './parser/xliff.js'; +import { XlocParser } from './parser/xloc.js'; export * from './parser/text.js'; export * from './parser/strings.js'; @@ -44,14 +50,14 @@ export class BasicExporter implements ExportLocalizations { }: { config: ExportConfig; parser: ExportParser; - outputFolder: string; + outputFolder?: string; }) { this.config = config; this.parser = parser; this.outputFolder = outputFolder; } - async export(): Promise { + async export(): Promise { let sourcePath = this.config.sourceLanguage.path; const outputFolder = await createOutputFolderIfNeed(this.outputFolder); for (const langConfig of this.config.targetLanguages) { @@ -74,3 +80,70 @@ export class BasicExporter implements ExportLocalizations { }; } } + +export class XcodeExporter implements ExportLocalizations { + private config: ExportConfig; + private projectPath: string; + private outputFolder?: string; + + constructor({ + config, + projectPath, + outputFolder, + }: { + config: ExportConfig; + projectPath: string; + outputFolder: string; + }) { + this.config = config; + this.projectPath = projectPath; + this.outputFolder = outputFolder; + } + + async export(): Promise { + // For xcode, we need export localizations first + const xcodeOutputFolder = await createTemporaryOutputFolder(); + logger.info(`Exporting Xcode project to ${xcodeOutputFolder}`); + const xcodeExporter = new XcodeExportLocalizations( + this.projectPath, + xcodeOutputFolder, + ); + const result = await xcodeExporter.export(); + // const result = { + // bundlePath: + // '/var/folders/x3/d3jx55kn439_kdfysr655ld00000gn/T/dolphin-export-mjIgGq/', + // languages: ['en', 'zh-Hans', 'ko', 'ja'], + // }; + logger.info( + `Exported Xcode project at ${result.bundlePath}, languages: ${result.languages}`, + ); + const exportConfig = { + sourceLanguage: { + code: this.config.sourceLanguage.code, + path: path.join( + result.bundlePath, + `${this.config.sourceLanguage.code}.xcloc`, + ), + }, + targetLanguages: result.languages.map((language: string) => { + let bundlePath = path.join(result.bundlePath, `${language}.xcloc`); + if (!path.isAbsolute(bundlePath)) { + bundlePath = path.join(this.config.basePath, bundlePath); + } + return { + code: language, + path: bundlePath, + }; + }), + basePath: this.config.basePath, + }; + let basicExporter = new BasicExporter({ + config: exportConfig, + parser: new XlocParser(), + outputFolder: this.outputFolder, + }); + const exportResult = await basicExporter.export(); + exportResult.intermediateBundlePath = result.bundlePath; + return exportResult; + } +} diff --git a/packages/ioloc/src/import/index.ts b/packages/ioloc/src/import/index.ts index 121566d..713d755 100644 --- a/packages/ioloc/src/import/index.ts +++ b/packages/ioloc/src/import/index.ts @@ -1,9 +1,16 @@ import { logger } from '@repo/base/logger'; import fs from 'node:fs'; +import path from 'node:path'; -import { ImportLocalizations, ImportLocalizationsResult } from '../index.js'; +import { + ImportLocalizations, + ImportLocalizationsResult, + LocalizationBundlePath, +} from '../index.js'; +import { XcodeImportLocalizations } from '../xcode.js'; import { parseXliff2Text } from '../xliff/index.js'; import { Xliff } from '../xliff/xliff-spec.js'; +import { XclocMerger } from './merger/xcloc.js'; export * from './merger/text.js'; export * from './merger/xliff.js'; @@ -70,3 +77,75 @@ export class BasicImporter implements ImportLocalizations { }; } } + +export class XcodeImporter implements ImportLocalizations { + private config: ImportConfig; + private localizationBundlePath: LocalizationBundlePath; + private projectPath: string; + private baseFolder: string; + + constructor({ + config, + projectPath, + baseFolder, + localizationBundlePath, + }: { + config: ImportConfig; + projectPath: string; + baseFolder: string; + localizationBundlePath: LocalizationBundlePath; + }) { + this.config = config; + this.localizationBundlePath = localizationBundlePath; + this.projectPath = projectPath; + this.baseFolder = baseFolder; + } + + async import(): Promise { + // Step 1: Use BasicImporter to localize strings to intermediateBundlePath + const intermediateBundlePath = + this.localizationBundlePath.intermediateBundlePath; + if (!intermediateBundlePath) { + throw new Error( + 'intermediateBundlePath is not set for importing xcode project', + ); + } + let importBundlePath = this.localizationBundlePath.bundlePath; + if (!path.isAbsolute(importBundlePath)) { + importBundlePath = path.join(this.baseFolder, importBundlePath); + } + const basicImporter = new BasicImporter({ + config: { + sourceLanguage: { + code: this.config.sourceLanguage.code, + path: path.join( + intermediateBundlePath, + `${this.config.sourceLanguage.code}.xcloc`, + ), + }, + targetLanguages: this.config.targetLanguages.map((lang) => ({ + ...lang, + to: path.join(intermediateBundlePath, `${lang.code}.xcloc`), + })), + }, + merger: new XclocMerger(), + }); + + const basicImportResult = await basicImporter.import(); + if (basicImportResult.code !== 0) { + return basicImportResult; + } + + // Step 2: Use XcodeImportLocalizations to import intermediateBundlePath to Xcode project + const xcodeImporter = new XcodeImportLocalizations(); + + const xcodeImportResult = await xcodeImporter.import({ + localizationBundlePath: intermediateBundlePath, + projectPath: this.projectPath, + baseFolder: this.baseFolder, + }); + + logger.info('Xcode import completed'); + return xcodeImportResult; + } +} diff --git a/packages/ioloc/src/index.ts b/packages/ioloc/src/index.ts index 9c0c641..c5eb35c 100644 --- a/packages/ioloc/src/index.ts +++ b/packages/ioloc/src/index.ts @@ -1,28 +1,42 @@ import { LocalizationConfig, LocalizationFormat } from '@repo/base/config'; +import { logger } from '@repo/base/logger'; import fs from 'node:fs'; import path from 'node:path'; -import { BasicExporter, ExportConfig, ExportParser } from './export/index.js'; +import { + BasicExporter, + ExportConfig, + ExportParser, + XcodeExporter, +} from './export/index.js'; import { JsonParser } from './export/parser/json.js'; import { AppleStringsParser } from './export/parser/strings.js'; import { TextParser } from './export/parser/text.js'; import { XliffParser } from './export/parser/xliff.js'; import { XlocParser } from './export/parser/xloc.js'; -import { BasicImporter, ImportConfig, ImportMerger } from './import/index.js'; +import { + BasicImporter, + ImportConfig, + ImportMerger, + XclocMerger, + XcodeImporter, +} from './import/index.js'; import { JsonMerger } from './import/merger/json.js'; import { AppleStringsMerger } from './import/merger/strings.js'; import { TextMerger } from './import/merger/text.js'; import { XliffMerger } from './import/merger/xliff.js'; -import { XcodeImportLocalizations } from './xcode.js'; +import { createTemporaryOutputFolder } from './utils.js'; +import { XcodeExportLocalizations, XcodeImportLocalizations } from './xcode.js'; export * from './utils.js'; -export type ExportLocalizationsResult = { +export type LocalizationBundlePath = { bundlePath: string; + intermediateBundlePath?: string; // Path to intermediate output artifacts. For example, after exporting from Xcode, the xcloc files will be stored in this folder and can be used for importing localizations afterwards. }; export interface ExportLocalizations { - export(): Promise; + export(): Promise; } export async function exportLocalizationBundle({ @@ -35,7 +49,7 @@ export async function exportLocalizationBundle({ baseLanguage: string; baseFolder: string; outputFolder: string; -}): Promise { +}): Promise { const format = config.format; if (!('languages' in config)) { throw new Error( @@ -46,7 +60,7 @@ export async function exportLocalizationBundle({ if (!path.isAbsolute(bundlePath)) { bundlePath = path.join(baseFolder, bundlePath); } - let exportConfig: ExportConfig = { + const exportConfig = { sourceLanguage: { code: baseLanguage, path: bundlePath, @@ -64,10 +78,14 @@ export async function exportLocalizationBundle({ basePath: baseFolder, }; let parser: ExportParser; - if ( - format === LocalizationFormat.XCODE || - format === LocalizationFormat.XCLOC - ) { + if (format === LocalizationFormat.XCODE) { + const exporter = new XcodeExporter({ + projectPath: bundlePath, + config: exportConfig, + outputFolder, + }); + return await exporter.export(); + } else if (format === LocalizationFormat.XCLOC) { parser = new XlocParser(); } else if (format === LocalizationFormat.TEXT) { parser = new TextParser(); @@ -95,7 +113,7 @@ export interface ImportLocalizationsResult { } export interface ImportLocalizations { - import(localizationBundlePath: string): Promise; + import(): Promise; } export async function importLocalizationBundle({ @@ -105,7 +123,7 @@ export async function importLocalizationBundle({ baseFolder, }: { config: LocalizationConfig; - localizationBundlePath: string; + localizationBundlePath: LocalizationBundlePath; baseLanguage: string; baseFolder: string; }): Promise { @@ -114,7 +132,7 @@ export async function importLocalizationBundle({ `languages is required for ${config.format} format in the configuration`, ); } - let importBundlePath = localizationBundlePath; + let importBundlePath = localizationBundlePath.bundlePath; if (!path.isAbsolute(importBundlePath)) { importBundlePath = path.join(baseFolder, importBundlePath); } @@ -140,16 +158,16 @@ export async function importLocalizationBundle({ }), }; let merger: ImportMerger; - if ( - config.format === LocalizationFormat.XCODE || - config.format === LocalizationFormat.XCLOC - ) { - const importLocalizations = new XcodeImportLocalizations( - config, + if (config.format === LocalizationFormat.XCODE) { + const importer = new XcodeImporter({ + config: importConfig, + localizationBundlePath, + projectPath: config.path, baseFolder, - ); - const result = await importLocalizations.import(importBundlePath); - return result; + }); + return await importer.import(); + } else if (config.format === LocalizationFormat.XCLOC) { + merger = new XclocMerger(); } else if (config.format === LocalizationFormat.TEXT) { merger = new TextMerger(); } else if (config.format === LocalizationFormat.STRINGS) { @@ -162,8 +180,7 @@ export async function importLocalizationBundle({ throw new Error(`Unsupported budnle format: ${config.format}`); } const importer = new BasicImporter({ config: importConfig, merger }); - const result = await importer.import(); - return result; + return await importer.import(); } export async function replaceBundle(bundlePath: string, other: string) { diff --git a/packages/ioloc/src/xcode.ts b/packages/ioloc/src/xcode.ts index 4977461..2c8cfb3 100644 --- a/packages/ioloc/src/xcode.ts +++ b/packages/ioloc/src/xcode.ts @@ -2,41 +2,35 @@ import { XcodeProject } from '@bacons/xcode'; import { CommonLocalizationConfig } from '@repo/base/config'; import { logger } from '@repo/base/logger'; import child_process from 'child_process'; +import { spawn } from 'child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { ExportLocalizations, ImportLocalizations } from './index.js'; import { createOutputFolderIfNeed } from './utils.js'; const XcodeCommonArgs = '-disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackageUpdates'; -export type ExportOptions = { - // The base language for translation. By default, it's using default localization language of the project. Normally you don't need provide this option. - baseLanguage?: string; -}; - -export type ExportResult = { - bundlePath: string; -}; - export type ImportOptions = {}; export type ImportResult = { code: number; }; -export class XcodeExportLocalizations implements ExportLocalizations { +export class XcodeExportLocalizations { constructor( - private config: CommonLocalizationConfig, + private projectPath: string, private baseFolder: string, private outputFolder?: string, ) {} - async export(): Promise { + async export(): Promise<{ + bundlePath: string; + languages: string[]; + }> { // check if xcodebuild command is available try { - child_process.execSync('/usr/bin/xcodebuild'); + child_process.execSync('/usr/bin/xcodebuild -version'); } catch (e) { throw new Error( 'xcodebuild command is not available. Make sure you have Xcode installed.', @@ -45,12 +39,12 @@ export class XcodeExportLocalizations implements ExportLocalizations { const outputFolder = await createOutputFolderIfNeed(this.outputFolder); - const projectPath = path.isAbsolute(this.config.path) - ? this.config.path - : path.join(this.baseFolder, this.config.path); + const absoluteProjectPath = path.isAbsolute(this.projectPath) + ? this.projectPath + : path.join(this.baseFolder, this.projectPath); // get all known regions const xcodeProject = XcodeProject.open( - path.join(projectPath, 'project.pbxproj'), + path.join(absoluteProjectPath, 'project.pbxproj'), ); const developmentRegion = xcodeProject.rootObject.props.developmentRegion; const knownRegions = xcodeProject.rootObject.props.knownRegions; @@ -58,7 +52,7 @@ export class XcodeExportLocalizations implements ExportLocalizations { // run exportLocaizations command let exportRegions: string[] = []; - let command = `/usr/bin/xcodebuild -exportLocalizations -project ${projectPath} -localizationPath ${outputFolder} ${XcodeCommonArgs}`; + let command = `/usr/bin/xcodebuild -exportLocalizations -project ${absoluteProjectPath} -localizationPath ${outputFolder} ${XcodeCommonArgs}`; for (const region of knownRegions) { if (region === 'Base') { // skip base region @@ -101,28 +95,33 @@ export class XcodeExportLocalizations implements ExportLocalizations { } return { bundlePath: outputFolder, + languages: exportRegions, }; } } -export class XcodeImportLocalizations implements ImportLocalizations { - constructor( - private config: CommonLocalizationConfig, - private baseFolder: string, - ) {} - async import(localizationBundlePath: string): Promise { +export class XcodeImportLocalizations { + async import({ + localizationBundlePath, + projectPath, + baseFolder, + }: { + localizationBundlePath: string; + projectPath: string; + baseFolder: string; + }): Promise { // check if xcodebuild command is available try { - child_process.execSync('/usr/bin/xcodebuild'); + child_process.execSync('/usr/bin/xcodebuild -version'); } catch (e) { throw new Error( 'xcodebuild command is not available. Make sure you have Xcode installed.', ); } - const projectPath = path.isAbsolute(this.config.path) - ? this.config.path - : path.join(this.baseFolder, this.config.path); + const absoluteProjectPath = path.isAbsolute(projectPath) + ? projectPath + : path.join(baseFolder, projectPath); // run importLocaizations command const xclocPaths = ( await fs.promises.readdir(localizationBundlePath, { withFileTypes: true }) @@ -135,7 +134,7 @@ export class XcodeImportLocalizations implements ImportLocalizations { .sort(); logger.info(`Found xcloc files: ${xclocPaths.join(', ')}`); for (const xclocPath of xclocPaths) { - const command = `/usr/bin/xcodebuild -importLocalizations -project ${projectPath} -localizationPath ${xclocPath} -mergeImport ${XcodeCommonArgs}`; + const command = `/usr/bin/xcodebuild -importLocalizations -project ${absoluteProjectPath} -localizationPath ${xclocPath} -mergeImport ${XcodeCommonArgs}`; logger.info(`Running command: ${command}`); const result = await exec(command); if (result.code !== 0) { @@ -150,21 +149,13 @@ export class XcodeImportLocalizations implements ImportLocalizations { } } -async function exec(command: string) { - const commands = command.split(' '); - if (!commands[0]) { - throw new Error(`Invalid command: ${command}`); - } - let child = child_process.spawn(commands[0], commands.slice(1)); - let error = ''; - for await (const chunk of child.stderr) { - error += chunk; - } - logger.error(error); - const exitCode = await new Promise((resolve, reject) => { - child.on('close', resolve); +async function exec(command: string): Promise<{ code: number }> { + return new Promise((resolve) => { + const [cmd, ...args] = command.split(' '); + const childProcess = spawn(cmd, args, { stdio: 'inherit' }); + + childProcess.on('close', (code) => { + resolve({ code: code ?? 0 }); + }); }); - return { - code: exitCode, - }; } diff --git a/packages/ioloc/src/xliff/index.ts b/packages/ioloc/src/xliff/index.ts index b7460fd..9aae2d9 100644 --- a/packages/ioloc/src/xliff/index.ts +++ b/packages/ioloc/src/xliff/index.ts @@ -112,7 +112,12 @@ export function stringifyXliff2( options?: StringifyOptions, ) { const doc = xliff.name === 'xliff' ? { elements: [xliff] } : xliff; - const opt = Object.assign({ spaces: 2 }, options, { compact: false }); + const opt = Object.assign({ spaces: 2 }, options, { + compact: false, + attributeValueFn: function (value: string) { + return encodeAttribute(value); + }, + }); return js2xml(doc, opt); } @@ -121,7 +126,12 @@ export function stringifyXliff1( options?: StringifyOptions, ) { const doc = xliff.name === 'xliff' ? { elements: [xliff] } : xliff; - const opt = Object.assign({ spaces: 2 }, options, { compact: false }); + const opt = Object.assign({ spaces: 2 }, options, { + compact: false, + attributeValueFn: function (value: string) { + return encodeAttribute(value); + }, + }); return js2xml(doc, opt); } @@ -312,3 +322,13 @@ function mergeSegments(source: Segment, target: Segment) { } return source; } + +const encodeAttribute = function (attributeValue: string) { + return attributeValue + .replace(/"/g, '"') // convert quote back before converting amp + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; diff --git a/packages/ioloc/tests/e2e.test.ts b/packages/ioloc/tests/e2e.test.ts index 018486c..d24fb3f 100644 --- a/packages/ioloc/tests/e2e.test.ts +++ b/packages/ioloc/tests/e2e.test.ts @@ -1,14 +1,14 @@ -import { LocalizationConfig, LocalizationFormat } from '@repo/base' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { expect, test } from 'vitest' +import { LocalizationConfig, LocalizationFormat } from '@repo/base/config'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'vitest'; import { exportLocalizationBundle, importLocalizationBundle, textHash, -} from '../src/index.js' +} from '../src/index.js'; test.each([ { @@ -50,48 +50,51 @@ test.each([ }) => { const targetFolder = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'dolphin-test-'), - ) - const baseFolder = path.join(__dirname, `./examples/${bundleFormat}/export`) + ); + const baseFolder = path.join( + __dirname, + `./examples/${bundleFormat}/export`, + ); const bundlePath = path.join( __dirname, `./examples/${bundleFormat}/export/$\{LANGUAGE\}.${baseFileExtension}`, - ) - const sourcePath = bundlePath.replaceAll('${LANGUAGE}', baseLanguage) - const fileId = textHash(sourcePath) + ); + const sourcePath = bundlePath.replaceAll('${LANGUAGE}', baseLanguage); + const fileId = textHash(sourcePath); const config: LocalizationConfig = { id: fileId, path: bundlePath, format: bundleFormat as LocalizationFormat, languages: targetLanguages, - } + }; await exportLocalizationBundle({ config, baseLanguage: baseLanguage, baseFolder: baseFolder, outputFolder: targetFolder, - }) + }); const exportedFiles = targetLanguages.map((language) => path.join(targetFolder, `${language}.xliff`), - ) + ); const expectedFileStrings = targetLanguages.map((language) => { const filePath = path.join( __dirname, `./examples/${bundleFormat}/export/`, `${language}.${targetFileExtension}`, - ) - const fileString = fs.readFileSync(filePath, 'utf-8') - const originalPath = bundlePath.replaceAll('${LANGUAGE}', language) + ); + const fileString = fs.readFileSync(filePath, 'utf-8'); + const originalPath = bundlePath.replaceAll('${LANGUAGE}', language); return fileString .replaceAll('${ORIGINAL_PATH}', path.relative(baseFolder, originalPath)) - .replaceAll('${FILE_ID}', fileId) - }) + .replaceAll('${FILE_ID}', fileId); + }); for (let i = 0; i < exportedFiles.length; i++) { expect(fs.readFileSync(exportedFiles[i], 'utf-8')).toStrictEqual( expectedFileStrings[i], - ) + ); } }, -) +); test.each([ ['text', 'txt', 'en', ['zh', 'ja']], @@ -102,39 +105,42 @@ test.each([ async (bundleFormat, fileExtension, baseLanguage, targetLanguages) => { const targetFolder = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'dolphin-test-'), - ) + ); const importBundlePath = path.join( __dirname, `./examples/${bundleFormat}/import`, - ) + ); const config: LocalizationConfig = { id: importBundlePath, path: path.join(targetFolder, '${LANGUAGE}.txt'), format: bundleFormat as LocalizationFormat, languages: targetLanguages, - } + }; await importLocalizationBundle({ config, - localizationBundlePath: importBundlePath, + localizationBundlePath: { + bundlePath: importBundlePath, + intermediateBundlePath: undefined, + }, baseLanguage, baseFolder: targetFolder, - }) + }); const importedFiles = targetLanguages.map((language) => path.join(targetFolder, `${language}.txt`), - ) + ); const expectedFileStrings = targetLanguages.map((language) => { const filePath = path.join( __dirname, `./examples/${bundleFormat}/import/`, `${language}.${fileExtension}`, - ) - const fileString = fs.readFileSync(filePath, 'utf-8') - return fileString - }) + ); + const fileString = fs.readFileSync(filePath, 'utf-8'); + return fileString; + }); for (let i = 0; i < importedFiles.length; i++) { expect(fs.readFileSync(importedFiles[i], 'utf-8')).toStrictEqual( expectedFileStrings[i], - ) + ); } }, -) +); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index faf90be..d59be9c 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -71,8 +71,20 @@ export async function translateBundle( } logger.info(`Merging ${xliffs.length} parsed files`); const mergedStrings = convertXliffsToEntities(xliffs); - const count = Object.keys(mergedStrings).length; - spinner?.succeed(chalk.green(`${count} strings to be translated\n`)); + let untranslatedMergedStrings: LocalizationEntityDictionary = {}; + for (const [key, entity] of Object.entries(mergedStrings)) { + if (entity.untranslatedLanguages.length > 0) { + untranslatedMergedStrings[key] = entity; + } + } + const count = Object.keys(untranslatedMergedStrings).length; + spinner?.succeed( + chalk.green( + `${count} strings to be translated, total: ${ + Object.keys(mergedStrings).length + }\n`, + ), + ); if (count === 0) { logger.info(`No strings found, skipping translation`); return {