Fallback to default locale and static resx key name generation (instead of random GUIDs) added.

This commit is contained in:
Kay Unkroth 2021-04-06 17:20:08 -07:00
parent 6ce7537260
commit be1138d356
9 changed files with 115 additions and 21 deletions

View File

@ -35,12 +35,16 @@ namespace MTCmd
new Option<FileInfo>( new Option<FileInfo>(
new string[]{ "--import-file", "-if" }, Strings.ifDescription).ExistingOnly(), new string[]{ "--import-file", "-if" }, Strings.ifDescription).ExistingOnly(),
new Option<string>( new Option<string>(
new string[]{ "--locale-id", "-lcid" }, Strings.lcidDescription) new string[]{ "--locale-id", "-lcid" }, Strings.lcidDescription),
new Option<bool>(
new string[]{ "--fallback-mode", "-fm" }, Strings.fallbackDescription),
new Option<string>(
new string[]{ "--key-prefix", "-kp" }, Strings.keyPrefixDescription)
}; };
// Note that the parameters of the handler method are matched according to the names of the options // Note that the parameters of the handler method are matched according to the names of the options
rootCommand.Handler = CommandHandler.Create<string, Mode, DirectoryInfo, FileInfo, string>((connectionString, mode, exportFolder, importFile, localeId) => rootCommand.Handler = CommandHandler.Create<string, Mode, DirectoryInfo, FileInfo, string, bool, string>((connectionString, mode, exportFolder, importFile, localeId, fallbackMode, keyPrefix) =>
{ {
try try
{ {
@ -51,13 +55,13 @@ namespace MTCmd
{ {
case Mode.Export: case Mode.Export:
case Mode.ExportResx: case Mode.ExportResx:
Export(mode, model, exportFolder, localeId); Export(mode, model, exportFolder, localeId, keyPrefix);
break; break;
case Mode.Import: case Mode.Import:
Import(model, importFile, false); Import(model, importFile, false, fallbackMode);
break; break;
case Mode.Overwrite: case Mode.Overwrite:
Import(model, importFile, true); Import(model, importFile, true, fallbackMode);
break; break;
default: default:
break; break;
@ -73,12 +77,12 @@ namespace MTCmd
return rootCommand.InvokeAsync(args).Result; return rootCommand.InvokeAsync(args).Result;
} }
static void Import(DataModel model, FileInfo importFile, bool overwriteMode) static void Import(DataModel model, FileInfo importFile, bool overwriteMode, bool fallbackDefault)
{ {
Func<string, bool> import = (lcid) => { Func<string, bool> import = (lcid) => {
if (Path.GetExtension(importFile.Name).Equals(".csv", StringComparison.InvariantCultureIgnoreCase)) if (Path.GetExtension(importFile.Name).Equals(".csv", StringComparison.InvariantCultureIgnoreCase))
{ {
model.ImportFromCsv(importFile.FullName, lcid, overwriteMode); model.ImportFromCsv(importFile.FullName, lcid, overwriteMode, fallbackDefault);
return true; return true;
} }
else if (Path.GetExtension(importFile.Name).Equals(".resx", StringComparison.InvariantCultureIgnoreCase)) else if (Path.GetExtension(importFile.Name).Equals(".resx", StringComparison.InvariantCultureIgnoreCase))
@ -88,7 +92,7 @@ namespace MTCmd
{ {
try try
{ {
model.ImportFromResx(importFile.FullName, referenceResx, lcid, overwriteMode); model.ImportFromResx(importFile.FullName, referenceResx, lcid, overwriteMode, fallbackDefault);
return true; return true;
} }
catch (NoResxMatchesException noMatch) catch (NoResxMatchesException noMatch)
@ -132,9 +136,9 @@ namespace MTCmd
} }
} }
static void Export(Mode mode, DataModel model, DirectoryInfo exportFolder, string lcid) static void Export(Mode mode, DataModel model, DirectoryInfo exportFolder, string lcid, string keyPrefix)
{ {
Action export = () => { if (mode == Mode.ExportResx) model.ExportToResx(exportFolder.FullName); else model.ExportToCsv(exportFolder.FullName); }; Action export = () => { if (mode == Mode.ExportResx) model.ExportToResx(exportFolder.FullName, keyPrefix); else model.ExportToCsv(exportFolder.FullName); };
if (exportFolder != null) if (exportFolder != null)
{ {

View File

@ -87,6 +87,15 @@ namespace MTCmd {
} }
} }
/// <summary>
/// Looks up a localized string similar to In Import mode, determines if the string from the default locale should be applied to the translated locale if the translated string is missing or cannot be found. The default setting is false, which leaves the translated string empty in the translated locale..
/// </summary>
internal static string fallbackDescription {
get {
return ResourceManager.GetString("fallbackDescription", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to The full path and name of the comma-separated values (csv) or resource (resx) translation file to import.. /// Looks up a localized string similar to The full path and name of the comma-separated values (csv) or resource (resx) translation file to import..
/// </summary> /// </summary>
@ -123,6 +132,15 @@ namespace MTCmd {
} }
} }
/// <summary>
/// Looks up a localized string similar to In ExportResx mode, influences how Metadata Translator generates the names of the key/value pairs. By default, a random GUID is generated as the name. If a key prefix is specified, then Metadata Translator generates keys based on the translated property types and the metadata object names..
/// </summary>
internal static string keyPrefixDescription {
get {
return ResourceManager.GetString("keyPrefixDescription", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to A valid Windows Language Code Identifier (LCID), aka language tag, such as en-US, af-NA, and zh-CN.. /// Looks up a localized string similar to A valid Windows Language Code Identifier (LCID), aka language tag, such as en-US, af-NA, and zh-CN..
/// </summary> /// </summary>

View File

@ -126,6 +126,9 @@
<data name="exportSuccess" xml:space="preserve"> <data name="exportSuccess" xml:space="preserve">
<value>Existing translations exported to: {0}</value> <value>Existing translations exported to: {0}</value>
</data> </data>
<data name="fallbackDescription" xml:space="preserve">
<value>In Import mode, determines if the string from the default locale should be applied to the translated locale if the translated string is missing or cannot be found. The default setting is false, which leaves the translated string empty in the translated locale.</value>
</data>
<data name="ifDescription" xml:space="preserve"> <data name="ifDescription" xml:space="preserve">
<value>The full path and name of the comma-separated values (csv) or resource (resx) translation file to import.</value> <value>The full path and name of the comma-separated values (csv) or resource (resx) translation file to import.</value>
</data> </data>
@ -138,6 +141,9 @@
<data name="invalidLocale" xml:space="preserve"> <data name="invalidLocale" xml:space="preserve">
<value>Either the imported language matches the default locale, or the file name does not correspond to a supported locale identifier. The file name convention is &lt;lcid&gt;.csv or &lt;lcid&gt;.resx.</value> <value>Either the imported language matches the default locale, or the file name does not correspond to a supported locale identifier. The file name convention is &lt;lcid&gt;.csv or &lt;lcid&gt;.resx.</value>
</data> </data>
<data name="keyPrefixDescription" xml:space="preserve">
<value>In ExportResx mode, influences how Metadata Translator generates the names of the key/value pairs. By default, a random GUID is generated as the name. If a key prefix is specified, then Metadata Translator generates keys based on the translated property types and the metadata object names.</value>
</data>
<data name="lcidDescription" xml:space="preserve"> <data name="lcidDescription" xml:space="preserve">
<value>A valid Windows Language Code Identifier (LCID), aka language tag, such as en-US, af-NA, and zh-CN.</value> <value>A valid Windows Language Code Identifier (LCID), aka language tag, such as en-US, af-NA, and zh-CN.</value>
</data> </data>

View File

@ -474,7 +474,7 @@ namespace Metadata_Translator
/// The files are placed into the specified export folder. /// The files are placed into the specified export folder.
/// </summary> /// </summary>
/// <param name="exportFolderPath"></param> /// <param name="exportFolderPath"></param>
public void ExportToResx(string exportFolderPath) public void ExportToResx(string exportFolderPath, string keyPrefix)
{ {
List<ExpandoObject> dataRows = GetAllDataRows(); List<ExpandoObject> dataRows = GetAllDataRows();
if (dataRows != null && dataRows.Count > 0) if (dataRows != null && dataRows.Count > 0)
@ -487,7 +487,7 @@ namespace Metadata_Translator
{ {
using (ResXResourceWriter resx = new ResXResourceWriter(System.IO.Path.Combine(exportFolderPath, $"{lcid}.resx"))) using (ResXResourceWriter resx = new ResXResourceWriter(System.IO.Path.Combine(exportFolderPath, $"{lcid}.resx")))
{ {
foreach(var kvp in dataRows.GetValues(ContainerColumnHeader, lcid)) foreach(var kvp in dataRows.GetKeyValuePairs(ContainerColumnHeader, lcid, keyPrefix))
{ {
resx.AddResource(kvp.Key.ToString(), kvp.Value); resx.AddResource(kvp.Key.ToString(), kvp.Value);
} }
@ -503,7 +503,7 @@ namespace Metadata_Translator
/// <param name="filePath"></param> /// <param name="filePath"></param>
/// <param name="lcid"></param> /// <param name="lcid"></param>
/// <param name="replaceExistingTranslations"></param> /// <param name="replaceExistingTranslations"></param>
public void ImportFromCsv(string filePath, string lcid, bool replaceExistingTranslations) public void ImportFromCsv(string filePath, string lcid, bool replaceExistingTranslations, bool fallbackToDefaultLocale = false)
{ {
string csvData = File.ReadAllText(filePath); string csvData = File.ReadAllText(filePath);
if (string.IsNullOrEmpty(csvData)) return; if (string.IsNullOrEmpty(csvData)) return;
@ -528,7 +528,7 @@ namespace Metadata_Translator
{ {
Type = textFields[0], Type = textFields[0],
Original = textFields[1], Original = textFields[1],
Translation = textFields[2] Translation = (fallbackToDefaultLocale && string.IsNullOrEmpty(textFields[2]))? textFields[1] : textFields[2]
}); });
} }
} }
@ -537,7 +537,7 @@ namespace Metadata_Translator
ApplyTranslation(lcid, parsedRows, replaceExistingTranslations); ApplyTranslation(lcid, parsedRows, replaceExistingTranslations);
} }
public void ImportFromResx(string filePath, string referencePath, string lcid, bool replaceExistingTranslations) public void ImportFromResx(string filePath, string referencePath, string lcid, bool replaceExistingTranslations, bool fallbackToDefaultLocale)
{ {
List<CsvRow> parsedRows = new List<CsvRow>(); List<CsvRow> parsedRows = new List<CsvRow>();
@ -549,6 +549,10 @@ namespace Metadata_Translator
string key = kvp.Key.ToString(); string key = kvp.Key.ToString();
string value = kvp.Value?.ToString(); string value = kvp.Value?.ToString();
string translation = translatedStrings.GetString(key); string translation = translatedStrings.GetString(key);
if (fallbackToDefaultLocale && string.IsNullOrEmpty(translation))
translation = value;
if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(translation)) if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(translation))
{ {
parsedRows.Add(new CsvRow parsedRows.Add(new CsvRow

View File

@ -24,5 +24,23 @@ namespace Metadata_Translator
$"DisplayFolder - {TabularObjects.Count} Objects" : $"DisplayFolder - {TabularObjects.Count} Objects" :
$"DisplayFolder - 1 {TabularObject.ObjectType}"; $"DisplayFolder - 1 {TabularObject.ObjectType}";
} }
protected override string GetFullName(MetadataObject metadataObject)
{
if (metadataObject is Measure measure)
{
return "#" + Convert.ToBase64String(Encoding.UTF8.GetBytes(measure.DisplayFolder));
}
else if (metadataObject is Column column)
{
return "#" + Convert.ToBase64String(Encoding.UTF8.GetBytes(column.DisplayFolder));
}
else if (metadataObject is Hierarchy hierarchy)
{
return "#" + Convert.ToBase64String(Encoding.UTF8.GetBytes(hierarchy.DisplayFolder));
}
return string.Empty;
}
} }
} }

View File

@ -34,5 +34,23 @@ namespace Metadata_Translator
return TabularObject.ObjectType.ToString(); return TabularObject.ObjectType.ToString();
} }
} }
public virtual string GetUniqueName(string namePrefix)
{
return (string.IsNullOrEmpty(namePrefix)) ? TemporaryObjectId.ToString() : $"{namePrefix}{GetFullName(TabularObject)}#{TranslatedProperty}";
}
protected virtual string GetFullName(MetadataObject metadataObject)
{
string fullName = string.Empty;
if(metadataObject is NamedMetadataObject tabularObject)
{
string parentName = (tabularObject.Parent != null) ? GetFullName(tabularObject.Parent) : string.Empty;
fullName = string.Concat(parentName, "#", tabularObject.Name);
}
return fullName;
}
} }
} }

View File

@ -132,7 +132,7 @@ The -ef option exports existing translations from a dataset. If you want to add
### Importing translations ### Importing translations
To import translations from a .csv file, you must specify full path to the import file by using the --import-file (-if) parameter. The file name must correspond to the locale identifier (LCID) of the target language. You must also specify the --mode (-m) parameter. Valid options are Import or Overwrite. Import applies translations for strings that have not been translated yet in the dataset. Overwrite, as the name implies, overwrites any existing translations in the dataset. Both, Import and Overwrite create new translations if you import a locale that does not yet exist in the dataset. To import translations from a .csv file, you must specify full path to the import file by using the --import-file (-if) parameter. The file name must correspond to the locale identifier (LCID) of the target language. You must also specify the --mode (-m) parameter. Valid options are Import or Overwrite. Import applies translations for strings that have not been translated yet in the dataset. Overwrite, as the name implies, overwrites any existing translations in the dataset. Both, Import and Overwrite add a new culture to the dataset if you import a locale that does not yet exist in the dataset.
The following command imports German translations from a .csv file called de-DE.csv into an AdventureWorks dataset hosted in Power BI, overwriting any existing German strings in the dataset: The following command imports German translations from a .csv file called de-DE.csv into an AdventureWorks dataset hosted in Power BI, overwriting any existing German strings in the dataset:
@ -142,6 +142,18 @@ The following command imports German translations from a .csv file called de-DE.
> >
> MTCmd.exe only imports one translation file at a time. To import multiple languages, run MTCmd.exe in a loop. > MTCmd.exe only imports one translation file at a time. To import multiple languages, run MTCmd.exe in a loop.
### Fallback to the default locale
By default, Metadata Translator leaves untranslated strings empty in the translated cultures. This is generally acceptable because Power BI can fall back to the default locale if a translated string cannot be found for a caption, description, or display folder. However, DAX measures using the USERCULTURE() function might require translated strings to be present. In order to accommodate these scenarios, you can set the --fallback-mode option to true so that the command line tool takes the string from the default locale and adds it to the translated culture if the translated string is missing or cannot be found.
The following command imports German translations from a .csv file called de-DE.csv into an AdventureWorks dataset hosted in Power BI, overwriting any existing German strings in the dataset and applying the strings from the default locale if any translations are missing:
`MTCmd -cs "powerbi://api.powerbi.com/v1.0/myorg/AdventureWorksSource;initial catalog=AdventureWorks" -if C:\Translations\de-DE.csv -m Overwrite -fm true`
> Note
>
> Only the command-line tool, MTCmd.exe, supports falling back to the strings of the default locale in import mode. The GUI tool, Metadata Translator.exe, does not provide an option to enable this operation.
### Working with resx files ### Working with resx files
While the graphical user interface of Metadata Translator only works with csv files, the command-line tool supports an additional option to work with XML resource (.resx) files as well. Resx files rely on a well-defined XML schema, including a header followed by data in name/value pairs. These files are often used for software localization. Among other things, Visual Studio provides a convenient interface for creating and maintaining resx files. And with command-line support for resx files in Metadata Translator, you can reuse these software translations by applying them to your Power BI datasets. While the graphical user interface of Metadata Translator only works with csv files, the command-line tool supports an additional option to work with XML resource (.resx) files as well. Resx files rely on a well-defined XML schema, including a header followed by data in name/value pairs. These files are often used for software localization. Among other things, Visual Studio provides a convenient interface for creating and maintaining resx files. And with command-line support for resx files in Metadata Translator, you can reuse these software translations by applying them to your Power BI datasets.
@ -182,6 +194,20 @@ For illustration, imagine the following scenario:
> >
> To avoid orphaned resx files, it is a good idea to always export all locales together. If you must export individual languages, make sure you export them into a separate (empty) folder to avoid possibly overwriting an existing resx file of the default locale. > To avoid orphaned resx files, it is a good idea to always export all locales together. If you must export individual languages, make sure you export them into a separate (empty) folder to avoid possibly overwriting an existing resx file of the default locale.
#### Generating static key names instead of random GUIDs
If you find GUID-based resx files difficult to work with, you can switch to more statically generated key names based on the translated property types and the metadata object names in your dataset. In this case, use the --key-prefix option to specify an arbitrary prefix in ExportResx mode. With a key prefix specified, Metadata Translator generates key names following the format: <key prefix>#<metadata object name>#<translated property type>.
The following command exports all translations from an AdventureWorks dataset hosted in Power BI into resx files in a folder called ExportedTranslations using static key names instead of random GUIDs in the resx files (see also the following screenshot for a generated resx):
`MTCmd -cs "powerbi://api.powerbi.com/v1.0/myorg/AdventureWorksSource;initial catalog=AdventureWorks" -ef C:\ExportedTranslations -m ExportResx`
![A resx with static key names instead of random GUIDs](https://github.com/microsoft/Analysis-Services/blob/master/MetadataTranslator/Metadata%20Translator/Documentation/Images/A%20resx%20with%20static%20key%20names%20instead%20of%20random%20GUIDs.png)
> Note
>
> Because Metadata Translator generates the static key names based on the translated property types and the metadata object names in your dataset, be aware that the static key names change if you re-export your translations into resx files following a rename of metadata objects in the dataset.
#### Importing translations from resx files #### Importing translations from resx files
Importing translations from resx files is practically identical to importing from csv files. Simply specify the resx import file by using the --import-file (-if) parameter. Metadata Translator detects the import mode based in the .resx file name extension. For more details, refer to the section "Importing translations" earlier in this document. Importing translations from resx files is practically identical to importing from csv files. Simply specify the resx import file by using the --import-file (-if) parameter. Metadata Translator detects the import mode based in the .resx file name extension. For more details, refer to the section "Importing translations" earlier in this document.

View File

@ -27,11 +27,11 @@ namespace Metadata_Translator
return values; return values;
} }
public static Dictionary<Guid, string> GetValues(this List<ExpandoObject> collection, string containerColumnName, string columnName) public static Dictionary<string, string> GetKeyValuePairs(this List<ExpandoObject> collection, string containerColumnName, string columnName, string namePrefix)
{ {
if (collection == null) return new Dictionary<Guid, string>(); if (collection == null) return new Dictionary<string, string>();
var values = new Dictionary<Guid, string>(); var values = new Dictionary<string, string>();
foreach (ExpandoObject row in collection) foreach (ExpandoObject row in collection)
{ {
var metaContainer = (MetadataObjectContainer)row.GetObject(containerColumnName); var metaContainer = (MetadataObjectContainer)row.GetObject(containerColumnName);
@ -41,7 +41,7 @@ namespace Metadata_Translator
{ {
try try
{ {
values.Add(metaContainer.TemporaryObjectId, columnValue); values.Add(metaContainer.GetUniqueName(namePrefix), columnValue);
} }
catch (Exception ex) catch (Exception ex)
{ {