diff --git a/MetadataTranslator/MTCmd/Program.cs b/MetadataTranslator/MTCmd/Program.cs index 5656c13..4b6dfe2 100644 --- a/MetadataTranslator/MTCmd/Program.cs +++ b/MetadataTranslator/MTCmd/Program.cs @@ -35,12 +35,16 @@ namespace MTCmd new Option( new string[]{ "--import-file", "-if" }, Strings.ifDescription).ExistingOnly(), new Option( - new string[]{ "--locale-id", "-lcid" }, Strings.lcidDescription) - }; + new string[]{ "--locale-id", "-lcid" }, Strings.lcidDescription), + new Option( + new string[]{ "--fallback-mode", "-fm" }, Strings.fallbackDescription), + new Option( + new string[]{ "--key-prefix", "-kp" }, Strings.keyPrefixDescription) + }; // Note that the parameters of the handler method are matched according to the names of the options - rootCommand.Handler = CommandHandler.Create((connectionString, mode, exportFolder, importFile, localeId) => + rootCommand.Handler = CommandHandler.Create((connectionString, mode, exportFolder, importFile, localeId, fallbackMode, keyPrefix) => { try { @@ -51,13 +55,13 @@ namespace MTCmd { case Mode.Export: case Mode.ExportResx: - Export(mode, model, exportFolder, localeId); + Export(mode, model, exportFolder, localeId, keyPrefix); break; case Mode.Import: - Import(model, importFile, false); + Import(model, importFile, false, fallbackMode); break; case Mode.Overwrite: - Import(model, importFile, true); + Import(model, importFile, true, fallbackMode); break; default: break; @@ -73,12 +77,12 @@ namespace MTCmd 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 import = (lcid) => { if (Path.GetExtension(importFile.Name).Equals(".csv", StringComparison.InvariantCultureIgnoreCase)) { - model.ImportFromCsv(importFile.FullName, lcid, overwriteMode); + model.ImportFromCsv(importFile.FullName, lcid, overwriteMode, fallbackDefault); return true; } else if (Path.GetExtension(importFile.Name).Equals(".resx", StringComparison.InvariantCultureIgnoreCase)) @@ -88,7 +92,7 @@ namespace MTCmd { try { - model.ImportFromResx(importFile.FullName, referenceResx, lcid, overwriteMode); + model.ImportFromResx(importFile.FullName, referenceResx, lcid, overwriteMode, fallbackDefault); return true; } 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) { diff --git a/MetadataTranslator/MTCmd/Strings.Designer.cs b/MetadataTranslator/MTCmd/Strings.Designer.cs index 92aa295..b2da225 100644 --- a/MetadataTranslator/MTCmd/Strings.Designer.cs +++ b/MetadataTranslator/MTCmd/Strings.Designer.cs @@ -87,6 +87,15 @@ namespace MTCmd { } } + /// + /// 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.. + /// + internal static string fallbackDescription { + get { + return ResourceManager.GetString("fallbackDescription", resourceCulture); + } + } + /// /// 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.. /// @@ -123,6 +132,15 @@ namespace MTCmd { } } + /// + /// 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.. + /// + internal static string keyPrefixDescription { + get { + return ResourceManager.GetString("keyPrefixDescription", resourceCulture); + } + } + /// /// 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.. /// diff --git a/MetadataTranslator/MTCmd/Strings.resx b/MetadataTranslator/MTCmd/Strings.resx index 6444fed..0edbd39 100644 --- a/MetadataTranslator/MTCmd/Strings.resx +++ b/MetadataTranslator/MTCmd/Strings.resx @@ -126,6 +126,9 @@ Existing translations exported to: {0} + + 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. + The full path and name of the comma-separated values (csv) or resource (resx) translation file to import. @@ -138,6 +141,9 @@ 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 <lcid>.csv or <lcid>.resx. + + 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. + A valid Windows Language Code Identifier (LCID), aka language tag, such as en-US, af-NA, and zh-CN. diff --git a/MetadataTranslator/Metadata Translator/Data/DataModel.cs b/MetadataTranslator/Metadata Translator/Data/DataModel.cs index cbc1d33..7cb7fb0 100644 --- a/MetadataTranslator/Metadata Translator/Data/DataModel.cs +++ b/MetadataTranslator/Metadata Translator/Data/DataModel.cs @@ -474,7 +474,7 @@ namespace Metadata_Translator /// The files are placed into the specified export folder. /// /// - public void ExportToResx(string exportFolderPath) + public void ExportToResx(string exportFolderPath, string keyPrefix) { List dataRows = GetAllDataRows(); 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"))) { - foreach(var kvp in dataRows.GetValues(ContainerColumnHeader, lcid)) + foreach(var kvp in dataRows.GetKeyValuePairs(ContainerColumnHeader, lcid, keyPrefix)) { resx.AddResource(kvp.Key.ToString(), kvp.Value); } @@ -503,7 +503,7 @@ namespace Metadata_Translator /// /// /// - 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); if (string.IsNullOrEmpty(csvData)) return; @@ -528,7 +528,7 @@ namespace Metadata_Translator { Type = textFields[0], 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); } - 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 parsedRows = new List(); @@ -549,6 +549,10 @@ namespace Metadata_Translator string key = kvp.Key.ToString(); string value = kvp.Value?.ToString(); string translation = translatedStrings.GetString(key); + + if (fallbackToDefaultLocale && string.IsNullOrEmpty(translation)) + translation = value; + if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(translation)) { parsedRows.Add(new CsvRow diff --git a/MetadataTranslator/Metadata Translator/Data/DisplayFolderContainer.cs b/MetadataTranslator/Metadata Translator/Data/DisplayFolderContainer.cs index ce03d0a..cf4d18f 100644 --- a/MetadataTranslator/Metadata Translator/Data/DisplayFolderContainer.cs +++ b/MetadataTranslator/Metadata Translator/Data/DisplayFolderContainer.cs @@ -24,5 +24,23 @@ namespace Metadata_Translator $"DisplayFolder - {TabularObjects.Count} Objects" : $"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; + } } } diff --git a/MetadataTranslator/Metadata Translator/Data/MetadataObjectContainer.cs b/MetadataTranslator/Metadata Translator/Data/MetadataObjectContainer.cs index 6ed2eb3..e9661c2 100644 --- a/MetadataTranslator/Metadata Translator/Data/MetadataObjectContainer.cs +++ b/MetadataTranslator/Metadata Translator/Data/MetadataObjectContainer.cs @@ -34,5 +34,23 @@ namespace Metadata_Translator 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; + } } } diff --git a/MetadataTranslator/Metadata Translator/Documentation/Images/A resx with static key names instead of random GUIDs.png b/MetadataTranslator/Metadata Translator/Documentation/Images/A resx with static key names instead of random GUIDs.png new file mode 100644 index 0000000..c8d0362 Binary files /dev/null and b/MetadataTranslator/Metadata Translator/Documentation/Images/A resx with static key names instead of random GUIDs.png differ diff --git a/MetadataTranslator/Metadata Translator/Documentation/README.md b/MetadataTranslator/Metadata Translator/Documentation/README.md index b1e67ba..93c3a76 100644 --- a/MetadataTranslator/Metadata Translator/Documentation/README.md +++ b/MetadataTranslator/Metadata Translator/Documentation/README.md @@ -132,7 +132,7 @@ The -ef option exports existing translations from a dataset. If you want to add ### 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: @@ -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. +### 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 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. +#### 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: ##. + +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 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. diff --git a/MetadataTranslator/Metadata Translator/Helpers/ListExtensions.cs b/MetadataTranslator/Metadata Translator/Helpers/ListExtensions.cs index 0f7e22b..6dfc28b 100644 --- a/MetadataTranslator/Metadata Translator/Helpers/ListExtensions.cs +++ b/MetadataTranslator/Metadata Translator/Helpers/ListExtensions.cs @@ -27,11 +27,11 @@ namespace Metadata_Translator return values; } - public static Dictionary GetValues(this List collection, string containerColumnName, string columnName) + public static Dictionary GetKeyValuePairs(this List collection, string containerColumnName, string columnName, string namePrefix) { - if (collection == null) return new Dictionary(); + if (collection == null) return new Dictionary(); - var values = new Dictionary(); + var values = new Dictionary(); foreach (ExpandoObject row in collection) { var metaContainer = (MetadataObjectContainer)row.GetObject(containerColumnName); @@ -41,7 +41,7 @@ namespace Metadata_Translator { try { - values.Add(metaContainer.TemporaryObjectId, columnValue); + values.Add(metaContainer.GetUniqueName(namePrefix), columnValue); } catch (Exception ex) {