feat(localize): add scripts to migrate away from legacy message IDs (#41026)

Adds a new flag to `localize-extract` called `--migrateMapFile` which will generate a JSON file
that can be used to map legacy message IDs to cannonical ones.

Also includes a new script called `localize-migrate` that can take the mapping file which was
generated by `localize-extract` and migrate all of the IDs in the files that were passed in.

PR Close #41026
This commit is contained in:
Kristiyan Kostadinov 2021-02-27 14:49:34 +01:00 committed by Andrew Kushnir
parent 531f0bfb4a
commit 1735430476
16 changed files with 787 additions and 10 deletions

View File

@ -4,7 +4,8 @@
"description": "Angular - library for localizing messages", "description": "Angular - library for localizing messages",
"bin": { "bin": {
"localize-translate": "./src/tools/src/translate/main.js", "localize-translate": "./src/tools/src/translate/main.js",
"localize-extract": "./src/tools/src/extract/main.js" "localize-extract": "./src/tools/src/extract/main.js",
"localize-migrate": "./src/tools/src/migrate/main.js"
}, },
"author": "angular", "author": "angular",
"license": "MIT", "license": "MIT",

View File

@ -12,7 +12,7 @@ import {ɵParsedMessage} from '@angular/localize';
import * as glob from 'glob'; import * as glob from 'glob';
import * as yargs from 'yargs'; import * as yargs from 'yargs';
import {DiagnosticHandlingStrategy} from '../diagnostics'; import {Diagnostics, DiagnosticHandlingStrategy} from '../diagnostics';
import {checkDuplicateMessages} from './duplicates'; import {checkDuplicateMessages} from './duplicates';
import {MessageExtractor} from './extraction'; import {MessageExtractor} from './extraction';
@ -23,6 +23,7 @@ import {Xliff1TranslationSerializer} from './translation_files/xliff1_translatio
import {Xliff2TranslationSerializer} from './translation_files/xliff2_translation_serializer'; import {Xliff2TranslationSerializer} from './translation_files/xliff2_translation_serializer';
import {XmbTranslationSerializer} from './translation_files/xmb_translation_serializer'; import {XmbTranslationSerializer} from './translation_files/xmb_translation_serializer';
import {FormatOptions, parseFormatOptions} from './translation_files/format_options'; import {FormatOptions, parseFormatOptions} from './translation_files/format_options';
import {LegacyMessageIdMigrationSerializer} from './translation_files/legacy_message_id_migration_serializer';
if (require.main === module) { if (require.main === module) {
process.title = 'Angular Localization Message Extractor (localize-extract)'; process.title = 'Angular Localization Message Extractor (localize-extract)';
@ -53,7 +54,9 @@ if (require.main === module) {
.option('f', { .option('f', {
alias: 'format', alias: 'format',
required: true, required: true,
choices: ['xmb', 'xlf', 'xlif', 'xliff', 'xlf2', 'xlif2', 'xliff2', 'json'], choices: [
'xmb', 'xlf', 'xlif', 'xliff', 'xlf2', 'xlif2', 'xliff2', 'json', 'legacy-migrate'
],
describe: 'The format of the translation file.', describe: 'The format of the translation file.',
type: 'string', type: 'string',
}) })
@ -108,17 +111,17 @@ if (require.main === module) {
const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn); const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn);
const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy; const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy;
const formatOptions = parseFormatOptions(options.formatOptions); const formatOptions = parseFormatOptions(options.formatOptions);
const format = options.f;
extractTranslations({ extractTranslations({
rootPath, rootPath,
sourceFilePaths, sourceFilePaths,
sourceLocale: options.l, sourceLocale: options.l,
format: options.f, format,
outputPath: options.o, outputPath: options.o,
logger, logger,
useSourceMaps: options.useSourceMaps, useSourceMaps: options.useSourceMaps,
useLegacyIds: options.useLegacyIds, useLegacyIds: format === 'legacy-migrate' || options.useLegacyIds,
duplicateMessageHandling, duplicateMessageHandling,
formatOptions, formatOptions,
fileSystem, fileSystem,
@ -202,8 +205,8 @@ export function extractTranslations({
} }
const outputPath = fs.resolve(rootPath, output); const outputPath = fs.resolve(rootPath, output);
const serializer = const serializer = getSerializer(
getSerializer(format, sourceLocale, fs.dirname(outputPath), useLegacyIds, formatOptions, fs); format, sourceLocale, fs.dirname(outputPath), useLegacyIds, formatOptions, fs, diagnostics);
const translationFile = serializer.serialize(messages); const translationFile = serializer.serialize(messages);
fs.ensureDir(fs.dirname(outputPath)); fs.ensureDir(fs.dirname(outputPath));
fs.writeFile(outputPath, translationFile); fs.writeFile(outputPath, translationFile);
@ -213,9 +216,10 @@ export function extractTranslations({
} }
} }
export function getSerializer( function getSerializer(
format: string, sourceLocale: string, rootPath: AbsoluteFsPath, useLegacyIds: boolean, format: string, sourceLocale: string, rootPath: AbsoluteFsPath, useLegacyIds: boolean,
formatOptions: FormatOptions = {}, fs: PathManipulation): TranslationSerializer { formatOptions: FormatOptions = {}, fs: PathManipulation,
diagnostics: Diagnostics): TranslationSerializer {
switch (format) { switch (format) {
case 'xlf': case 'xlf':
case 'xlif': case 'xlif':
@ -233,6 +237,8 @@ export function getSerializer(
return new SimpleJsonTranslationSerializer(sourceLocale); return new SimpleJsonTranslationSerializer(sourceLocale);
case 'arb': case 'arb':
return new ArbTranslationSerializer(sourceLocale, rootPath, fs); return new ArbTranslationSerializer(sourceLocale, rootPath, fs);
case 'legacy-migrate':
return new LegacyMessageIdMigrationSerializer(diagnostics);
} }
throw new Error(`No translation serializer can handle the provided format: ${format}`); throw new Error(`No translation serializer can handle the provided format: ${format}`);
} }

View File

@ -0,0 +1,50 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ɵParsedMessage as ParsedMessage} from '@angular/localize';
import {Diagnostics} from '../../diagnostics';
import {TranslationSerializer} from './translation_serializer';
/**
* A translation serializer that generates the mapping file for the legacy message ID migration.
* The file is used by the `localize-migrate` script to migrate existing translation files from
* the legacy message IDs to the canonical ones.
*/
export class LegacyMessageIdMigrationSerializer implements TranslationSerializer {
constructor(private _diagnostics: Diagnostics) {}
serialize(messages: ParsedMessage[]): string {
let hasMessages = false;
const mapping = messages.reduce((output, message) => {
if (shouldMigrate(message)) {
for (const legacyId of message.legacyIds!) {
if (output.hasOwnProperty(legacyId)) {
this._diagnostics.warn(`Detected duplicate legacy ID ${legacyId}.`);
}
output[legacyId] = message.id;
hasMessages = true;
}
}
return output;
}, {} as Record<string, string>);
if (!hasMessages) {
this._diagnostics.warn(
'Could not find any legacy message IDs in source files while generating ' +
'the legacy message migration file.');
}
return JSON.stringify(mapping, null, 2);
}
}
/** Returns true if a message needs to be migrated. */
function shouldMigrate(message: ParsedMessage): boolean {
return !message.customId && !!message.legacyIds && message.legacyIds.length > 0;
}

View File

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {getFileSystem, NodeJSFileSystem, setFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import {ConsoleLogger, Logger, LogLevel} from '@angular/compiler-cli/src/ngtsc/logging';
import * as glob from 'glob';
import * as yargs from 'yargs';
import {migrateFile, MigrationMapping} from './migrate';
if (require.main === module) {
const args = process.argv.slice(2);
const options =
yargs
.option('r', {
alias: 'root',
default: '.',
describe: 'The root path for other paths provided in these options.\n' +
'This should either be absolute or relative to the current working directory.',
type: 'string',
})
.option('f', {
alias: 'files',
required: true,
describe:
'A glob pattern indicating what files to migrate. This should be relative to the root path',
type: 'string',
})
.option('m', {
alias: 'mapFile',
required: true,
describe:
'Path to the migration mapping file generated by `localize-extract`. This should be relative to the root path.',
type: 'string',
})
.strict()
.help()
.parse(args);
const fs = new NodeJSFileSystem();
setFileSystem(fs);
const rootPath = options.r;
const translationFilePaths = glob.sync(options.f, {cwd: rootPath, nodir: true});
const logger = new ConsoleLogger(LogLevel.warn);
migrateFiles({rootPath, translationFilePaths, mappingFilePath: options.m, logger});
process.exit(0);
}
export interface MigrateFilesOptions {
/**
* The base path for other paths provided in these options.
* This should either be absolute or relative to the current working directory.
*/
rootPath: string;
/** Paths to the files that should be migrated. Should be relative to the `rootPath`. */
translationFilePaths: string[];
/** Path to the file containing the message ID mappings. Should be relative to the `rootPath`. */
mappingFilePath: string;
/** Logger to use for diagnostic messages. */
logger: Logger;
}
/** Migrates the legacy message IDs based on the passed in configuration. */
export function migrateFiles({
rootPath,
translationFilePaths,
mappingFilePath,
logger,
}: MigrateFilesOptions) {
const fs = getFileSystem();
const absoluteMappingPath = fs.resolve(rootPath, mappingFilePath);
const mapping = JSON.parse(fs.readFile(absoluteMappingPath)) as MigrationMapping;
if (Object.keys(mapping).length === 0) {
logger.warn(
`Mapping file at ${absoluteMappingPath} is empty. Either there are no messages ` +
`that need to be migrated, or the extraction step failed to find them.`);
} else {
translationFilePaths.forEach(path => {
const absolutePath = fs.resolve(rootPath, path);
const sourceCode = fs.readFile(absolutePath);
fs.writeFile(absolutePath, migrateFile(sourceCode, mapping));
});
}
}

View File

@ -0,0 +1,30 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/** Mapping between legacy message IDs and their cannonical counterparts. */
export type MigrationMapping = {
[legacyId: string]: string;
};
/** Migrates the legacy message IDs within a single file. */
export function migrateFile(sourceCode: string, mapping: MigrationMapping) {
const legacyIds = Object.keys(mapping);
for (const legacyId of legacyIds) {
const cannonicalId = mapping[legacyId];
const pattern = new RegExp(escapeRegExp(legacyId), 'g');
sourceCode = sourceCode.replace(pattern, cannonicalId);
}
return sourceCode;
}
/** Escapes special regex characters in a string. */
function escapeRegExp(str: string): string {
return str.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
}

View File

@ -494,6 +494,49 @@ runInNativeFileSystem(() => {
`}`, `}`,
].join('\n')); ].join('\n'));
}); });
it('should generate the migration map file, if requested', () => {
extractTranslations({
rootPath,
sourceLocale: 'en',
sourceFilePaths: [sourceFilePath],
format: 'legacy-migrate',
outputPath,
logger,
useSourceMaps: false,
useLegacyIds: true,
duplicateMessageHandling: 'ignore',
fileSystem: fs
});
expect(fs.readFile(outputPath)).toEqual([
`{`,
` "1234567890123456789012345678901234567890": "273296103957933077",`,
` "12345678901234567890": "273296103957933077"`,
`}`,
].join('\n'));
});
it('should log a warning if there are no legacy message IDs to migrate', () => {
extractTranslations({
rootPath,
sourceLocale: 'en',
sourceFilePaths: [textFile1],
format: 'legacy-migrate',
outputPath,
logger,
useSourceMaps: false,
useLegacyIds: true,
duplicateMessageHandling: 'ignore',
fileSystem: fs
});
expect(fs.readFile(outputPath)).toBe('{}');
expect(logger.logs.warn).toEqual([[
'Messages extracted with warnings\n' +
'WARNINGS:\n' +
' - Could not find any legacy message IDs in source files while generating the legacy message migration file.'
]]);
});
}); });
}); });
}); });

View File

@ -0,0 +1,79 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ɵParsedMessage} from '@angular/localize';
import {Diagnostics} from '../../../src/diagnostics';
import {LegacyMessageIdMigrationSerializer} from '../../../src/extract/translation_files/legacy_message_id_migration_serializer';
import {mockMessage} from './mock_message';
// Doesn't need to run in each file system since it doesn't interact with the file system.
describe('LegacyMessageIdMigrationSerializer', () => {
let serializer: LegacyMessageIdMigrationSerializer;
beforeEach(() => {
serializer = new LegacyMessageIdMigrationSerializer(new Diagnostics());
});
it('should convert a set of parsed messages into a migration mapping file', () => {
const messages: ɵParsedMessage[] = [
mockMessage('one', [], [], {legacyIds: ['legacy-one', 'other-legacy-one']}),
mockMessage('two', [], [], {legacyIds: ['legacy-two']}),
mockMessage('three', [], [], {legacyIds: ['legacy-three', 'other-legacy-three']}),
];
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'{',
' "legacy-one": "one",',
' "other-legacy-one": "one",',
' "legacy-two": "two",',
' "legacy-three": "three",',
' "other-legacy-three": "three"',
'}',
]);
});
it('should not include messages that have a custom ID', () => {
const messages: ɵParsedMessage[] = [
mockMessage('one', [], [], {legacyIds: ['legacy-one']}),
mockMessage('two', [], [], {legacyIds: ['legacy-two'], customId: 'custom-two'}),
mockMessage('three', [], [], {legacyIds: ['legacy-three']}),
];
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'{',
' "legacy-one": "one",',
' "legacy-three": "three"',
'}',
]);
});
it('should not include messages that do not have legacy IDs', () => {
const messages: ɵParsedMessage[] = [
mockMessage('one', [], [], {legacyIds: ['legacy-one']}),
mockMessage('two', [], [], {}),
mockMessage('three', [], [], {legacyIds: ['legacy-three']}),
];
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'{',
' "legacy-one": "one",',
' "legacy-three": "three"',
'}',
]);
});
it('should produce an empty file if none of the messages need to be migrated', () => {
const messages: ɵParsedMessage[] = [
mockMessage('one', [], [], {legacyIds: ['legacy-one'], customId: 'custom-one'}),
mockMessage('two', [], [], {}),
mockMessage('three', [], [], {legacyIds: []}),
];
const output = serializer.serialize(messages);
expect(output).toBe('{}');
});
});

View File

@ -0,0 +1,40 @@
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin")
ts_library(
name = "test_lib",
testonly = True,
srcs = glob(
["**/*_spec.ts"],
),
deps = [
"//packages:types",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/compiler-cli/src/ngtsc/file_system/testing",
"//packages/compiler-cli/src/ngtsc/logging",
"//packages/compiler-cli/src/ngtsc/logging/testing",
"//packages/compiler-cli/src/ngtsc/testing",
"//packages/localize/src/tools",
"//packages/localize/src/tools/test:test_lib",
"//packages/localize/src/tools/test/helpers",
],
)
# Use copy_to_bin since filegroup doesn't seem to work on Windows.
copy_to_bin(
name = "test_files",
srcs = glob(["test_files/**/*"]),
)
jasmine_node_test(
name = "integration",
bootstrap = ["//tools/testing:node_no_angular_es5"],
data = [
":test_files",
],
deps = [
":test_lib",
"@npm//glob",
"@npm//yargs",
],
)

View File

@ -0,0 +1,196 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MockLogger} from '@angular/compiler-cli/src/ngtsc/logging/testing';
import {loadTestDirectory} from '@angular/compiler-cli/src/ngtsc/testing';
import {migrateFiles} from '../../../src/migrate/main';
import {runInNativeFileSystem} from '../../helpers';
runInNativeFileSystem(() => {
let fs: FileSystem;
let logger: MockLogger;
let rootPath: AbsoluteFsPath;
let mappingFilePath: AbsoluteFsPath;
beforeEach(() => {
fs = getFileSystem();
logger = new MockLogger();
rootPath = absoluteFrom('/project');
mappingFilePath = fs.resolve(rootPath, 'test_files/mapping.json');
loadTestDirectory(fs, __dirname + '/test_files', absoluteFrom('/project/test_files'));
});
describe('migrateFiles()', () => {
it('should log a warning if the migration file is empty', () => {
const emptyMappingPath = fs.resolve(rootPath, 'test_files/empty-mapping.json');
migrateFiles({
rootPath,
translationFilePaths: ['test_files/messages.json'],
logger,
mappingFilePath: emptyMappingPath,
});
expect(logger.logs.warn).toEqual([
[`Mapping file at ${emptyMappingPath} is empty. Either there are no messages ` +
`that need to be migrated, or the extraction step failed to find them.`]
]);
});
it('should migrate a json message file', () => {
const filePath = 'test_files/messages.json';
migrateFiles({
rootPath,
translationFilePaths: [filePath],
logger,
mappingFilePath,
});
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
`{`,
` "locale": "en-GB",`,
` "translations": {`,
` "9876543": "Hello",`,
` "custom-id": "Custom id message",`,
` "987654321098765": "Goodbye"`,
` }`,
`}`,
].join('\n'));
});
it('should migrate an arb message file', () => {
const filePath = 'test_files/messages.arb';
migrateFiles({
rootPath,
translationFilePaths: [filePath],
logger,
mappingFilePath,
});
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
`{`,
` "@@locale": "en-GB",`,
` "9876543": "Hello",`,
` "@9876543": {`,
` "x-locations": [`,
` {`,
` "file": "test.js",`,
` "start": { "line": "1", "column": "0" },`,
` "end": { "line": "1", "column": "0" }`,
` }`,
` ]`,
` },`,
` "custom-id": "Custom id message",`,
` "@custom-id": {`,
` "x-locations": [`,
` {`,
` "file": "test.js",`,
` "start": { "line": "2", "column": "0" },`,
` "end": { "line": "2", "column": "0" }`,
` }`,
` ]`,
` },`,
` "987654321098765": "Goodbye",`,
` "@987654321098765": {`,
` "x-locations": [`,
` {`,
` "file": "test.js",`,
` "start": { "line": "3", "column": "0" },`,
` "end": { "line": "3", "column": "0" }`,
` }`,
` ]`,
` }`,
`}`,
].join('\n'));
});
it('should migrate an xmb message file', () => {
const filePath = 'test_files/messages.xmb';
migrateFiles({
rootPath,
translationFilePaths: [filePath],
logger,
mappingFilePath,
});
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
`<?xml version="1.0" encoding="UTF-8" ?>`,
`<!DOCTYPE messagebundle [`,
`<!ELEMENT messagebundle (msg)*>`,
`<!ATTLIST messagebundle class CDATA #IMPLIED>`,
``,
`<!ELEMENT msg (#PCDATA|ph|source)*>`,
`<!ATTLIST msg id CDATA #IMPLIED>`,
`<!ATTLIST msg seq CDATA #IMPLIED>`,
`<!ATTLIST msg name CDATA #IMPLIED>`,
`<!ATTLIST msg desc CDATA #IMPLIED>`,
`<!ATTLIST msg meaning CDATA #IMPLIED>`,
`<!ATTLIST msg obsolete (obsolete) #IMPLIED>`,
`<!ATTLIST msg xml:space (default|preserve) "default">`,
`<!ATTLIST msg is_hidden CDATA #IMPLIED>`,
``,
`<!ELEMENT source (#PCDATA)>`,
``,
`<!ELEMENT ph (#PCDATA|ex)*>`,
`<!ATTLIST ph name CDATA #REQUIRED>`,
``,
`<!ELEMENT ex (#PCDATA)>`,
`]>`,
`<messagebundle>`,
` <msg id="9876543"><source>test.js:1</source>Hello</msg>`,
` <msg id="custom-id"><source>test.js:2</source>Custom id message</msg>`,
` <msg id="987654321098765"><source>test.js:3</source>Goodbye</msg>`,
`</messagebundle>`,
].join('\n'));
});
it('should migrate an xlf message file', () => {
const filePath = 'test_files/messages.xlf';
migrateFiles({
rootPath,
translationFilePaths: [filePath],
logger,
mappingFilePath,
});
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
`<?xml version="1.0" encoding="UTF-8" ?>`,
`<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-GB">`,
` <file id="ngi18n" original="ng.template" xml:space="preserve">`,
` <unit id="9876543">`,
` <notes>`,
` <note category="location">test.js:1</note>`,
` </notes>`,
` <segment>`,
` <source>Hello</source>`,
` </segment>`,
` </unit>`,
` <unit id="custom-id">`,
` <notes>`,
` <note category="location">test.js:2</note>`,
` </notes>`,
` <segment>`,
` <source>Custom id message</source>`,
` </segment>`,
` </unit>`,
` <unit id="987654321098765">`,
` <notes>`,
` <note category="location">test.js:3</note>`,
` </notes>`,
` <segment>`,
` <source>Goodbye</source>`,
` </segment>`,
` </unit>`,
` </file>`,
`</xliff>`,
].join('\n'));
});
/** Reads a path from the file system and normalizes the line endings. */
function readAndNormalize(path: AbsoluteFsPath): string {
return fs.readFile(path).replace(/\r?\n/g, '\n');
}
});
});

View File

@ -0,0 +1,4 @@
{
"1234567890123456789012345678901234567890": "987654321098765",
"12345678901234567890": "9876543"
}

View File

@ -0,0 +1,33 @@
{
"@@locale": "en-GB",
"12345678901234567890": "Hello",
"@12345678901234567890": {
"x-locations": [
{
"file": "test.js",
"start": { "line": "1", "column": "0" },
"end": { "line": "1", "column": "0" }
}
]
},
"custom-id": "Custom id message",
"@custom-id": {
"x-locations": [
{
"file": "test.js",
"start": { "line": "2", "column": "0" },
"end": { "line": "2", "column": "0" }
}
]
},
"1234567890123456789012345678901234567890": "Goodbye",
"@1234567890123456789012345678901234567890": {
"x-locations": [
{
"file": "test.js",
"start": { "line": "3", "column": "0" },
"end": { "line": "3", "column": "0" }
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"locale": "en-GB",
"translations": {
"12345678901234567890": "Hello",
"custom-id": "Custom id message",
"1234567890123456789012345678901234567890": "Goodbye"
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-GB">
<file id="ngi18n" original="ng.template" xml:space="preserve">
<unit id="12345678901234567890">
<notes>
<note category="location">test.js:1</note>
</notes>
<segment>
<source>Hello</source>
</segment>
</unit>
<unit id="custom-id">
<notes>
<note category="location">test.js:2</note>
</notes>
<segment>
<source>Custom id message</source>
</segment>
</unit>
<unit id="1234567890123456789012345678901234567890">
<notes>
<note category="location">test.js:3</note>
</notes>
<segment>
<source>Goodbye</source>
</segment>
</unit>
</file>
</xliff>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE messagebundle [
<!ELEMENT messagebundle (msg)*>
<!ATTLIST messagebundle class CDATA #IMPLIED>
<!ELEMENT msg (#PCDATA|ph|source)*>
<!ATTLIST msg id CDATA #IMPLIED>
<!ATTLIST msg seq CDATA #IMPLIED>
<!ATTLIST msg name CDATA #IMPLIED>
<!ATTLIST msg desc CDATA #IMPLIED>
<!ATTLIST msg meaning CDATA #IMPLIED>
<!ATTLIST msg obsolete (obsolete) #IMPLIED>
<!ATTLIST msg xml:space (default|preserve) "default">
<!ATTLIST msg is_hidden CDATA #IMPLIED>
<!ELEMENT source (#PCDATA)>
<!ELEMENT ph (#PCDATA|ex)*>
<!ATTLIST ph name CDATA #REQUIRED>
<!ELEMENT ex (#PCDATA)>
]>
<messagebundle>
<msg id="12345678901234567890"><source>test.js:1</source>Hello</msg>
<msg id="custom-id"><source>test.js:2</source>Custom id message</msg>
<msg id="1234567890123456789012345678901234567890"><source>test.js:3</source>Goodbye</msg>
</messagebundle>

View File

@ -0,0 +1,136 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {migrateFile} from '../../src/migrate/migrate';
describe('migrateFile', () => {
it('should migrate all of the legacy message IDs', () => {
const source = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="123hello-legacy" datatype="html">
<source>Hello</source>
<target>Bonjour</target>
</trans-unit>
<trans-unit id="456goodbye-legacy" datatype="html">
<source>Goodbye</source>
<target>Au revoir</target>
</trans-unit>
</body>
</file>
</xliff>
`;
const result = migrateFile(source, {
'123hello-legacy': 'hello-migrated',
'456goodbye-legacy': 'goodbye-migrated',
});
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
expect(result).toContain('<trans-unit id="goodbye-migrated" datatype="html">');
});
it('should migrate messages whose ID contains special regex characters', () => {
const source = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="123hello(.*legacy" datatype="html">
<source>Hello</source>
<target>Bonjour</target>
</trans-unit>
</body>
</file>
</xliff>
`;
const result = migrateFile(source, {'123hello(.*legacy': 'hello-migrated'});
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
});
it('should not migrate messages that are not in the mapping', () => {
const source = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="123hello-legacy" datatype="html">
<source>Hello</source>
<target>Bonjour</target>
</trans-unit>
<trans-unit id="456goodbye" datatype="html">
<source>Goodbye</source>
<target>Au revoir</target>
</trans-unit>
</body>
</file>
</xliff>
`;
const result = migrateFile(source, {'123hello-legacy': 'hello-migrated'});
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
expect(result).toContain('<trans-unit id="456goodbye" datatype="html">');
});
it('should not modify the file if none of the mappings match', () => {
const source = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="123hello-legacy" datatype="html">
<source>Hello</source>
<target>Bonjour</target>
</trans-unit>
<trans-unit id="456goodbye-legacy" datatype="html">
<source>Goodbye</source>
<target>Au revoir</target>
</trans-unit>
</body>
</file>
</xliff>
`;
const result = migrateFile(source, {
'does-not-match': 'migrated-does-not-match',
'also-does-not-match': 'migrated-also-does-not-match',
});
expect(result).toBe(source);
});
// Note: it shouldn't be possible for the ID to be repeated multiple times, but
// this assertion is here to make sure that it behaves as expected if it does.
it('should migrate if an ID appears in more than one place', () => {
const source = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="123hello-legacy" datatype="html">
<source>Hello</source>
<target>Bonjour</target>
</trans-unit>
<made-up-tag datatype="html" belongs-to="123hello-legacy">
<source id="123hello-legacy">Hello</source>
<target target-id="123hello-legacy">Bonjour</target>
</mage-up-tag>
</body>
</file>
</xliff>
`;
const result = migrateFile(source, {'123hello-legacy': 'hello-migrated'});
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
expect(result).toContain('<made-up-tag datatype="html" belongs-to="hello-migrated">');
expect(result).toContain('<source id="hello-migrated">');
expect(result).toContain('<target target-id="hello-migrated">Bonjour</target>');
});
});