Search Unity

Import / Export Localisation Tables

Discussion in 'Localization Tools' started by fffMalzbier, Jul 1, 2019.

  1. fffMalzbier

    fffMalzbier

    Joined:
    Jun 14, 2011
    Posts:
    3,276
    A feature Im missing so far is a way to import / export the data localisation Tables from the editor to send the data to a translation department / company.
    A import interface for Json / XML or a custom exporter interface would be nice.

    Manually copy pasting Thousands of strings to and from another data source is time consuming and error prone.
     
    Only4gamers likes this.
  2. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    Its on our road map. We are working on supporting import/export for several file types and services.
     
    Only4gamers and Korindian like this.
  3. fffMalzbier

    fffMalzbier

    Joined:
    Jun 14, 2011
    Posts:
    3,276
    Good to know :)
     
    karl_jones likes this.
  4. Alexis-Dev

    Alexis-Dev

    Joined:
    Apr 16, 2019
    Posts:
    121
    Hi,

    Some news about this features? :)

    Alexis
     
  5. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    Hi. We are working on a Google sheets import export which should be available in the next release. We are also adding xliff which may be in the same release, or the next one.
    Several more formats will follow.
    These are editor only features at the moment.
     
    Only4gamers and Alexis-Dev like this.
  6. Exentro

    Exentro

    Joined:
    Oct 8, 2013
    Posts:
    36
    I started testing a bit the localization tool and came with that for importing/exporting strings to csv.
    I just tried with a single StringTable with 2 entries so far so don't know if it scales without issue, but it may help waiting the next release.

    Code (CSharp):
    1. using System.Collections.Generic;
    2. using System.Globalization;
    3. using System.IO;
    4. using UnityEditor;
    5. using UnityEditor.Localization;
    6. using UnityEngine;
    7. using UnityEngine.Localization.Tables;
    8.  
    9. public class LocalizationWindow : EditorWindow
    10. {
    11.     [MenuItem("Tools/LocalizationCSV")]
    12.     public static void ShowWindow()
    13.     {
    14.         GetWindow(typeof(LocalizationWindow));
    15.     }
    16.  
    17.     private void OnEnable()
    18.     {
    19.         if (string.IsNullOrEmpty(CsvFilePath) || !Directory.Exists(Application.dataPath))
    20.         {
    21.             CsvFilePath = $"{Application.dataPath}{Path.DirectorySeparatorChar}localization_export.csv";
    22.         }
    23.     }
    24.  
    25.     [SerializeField] private string CsvFilePath;
    26.     private const char _CSV_SEPARATOR_ = ';';
    27.  
    28.     void OnGUI()
    29.     {
    30.         CsvFilePath = EditorGUILayout.TextField("CSV File Path ", CsvFilePath);
    31.         EditorGUILayout.BeginHorizontal();
    32.         if(GUILayout.Button("Export to CSV"))
    33.         {
    34.             CSVExporter.ToCSV(CsvFilePath);
    35.         }
    36.         if (GUILayout.Button("Import from CSV"))
    37.         {
    38.             CSVImporter.FromCSV(CsvFilePath);
    39.         }
    40.         EditorGUILayout.EndHorizontal();
    41.     }
    42.  
    43.     private static class CSVExporter
    44.     {
    45.         public static void ToCSV(string path)
    46.         {
    47.             foreach (AssetTableCollection collection in LocalizationEditorSettings.GetAssetTablesCollection<StringTable>())
    48.             {
    49.                 string collectionName = collection.TableName;
    50.  
    51.                 List<CultureInfo> cultures = GetCulturesInfo(collection);
    52.                 List<string> keys = GetKeys(collection.Tables[0]);
    53.  
    54.                 using (var writer = new StreamWriter(path))
    55.                 {
    56.                     WriteHeadLine(writer, collectionName, cultures);
    57.                     WriteKeysLines(writer, collection, keys);
    58.                 }
    59.  
    60.                 Debug.Log($"Csv file path : {path}");
    61.             }
    62.         }
    63.  
    64.         private static List<CultureInfo> GetCulturesInfo(AssetTableCollection collection)
    65.         {
    66.             List<CultureInfo> cultures = new List<CultureInfo>();
    67.             foreach (LocalizedTable table in collection.Tables)
    68.             {
    69.                 cultures.Add(table.LocaleIdentifier.CultureInfo);
    70.             }
    71.             return cultures;
    72.         }
    73.  
    74.         private static List<string> GetKeys(LocalizedTable table)
    75.         {
    76.             List<string> keys = new List<string>();
    77.             foreach (SharedTableData.SharedTableEntry entry in table.SharedData.Entries)
    78.             {
    79.                 keys.Add(entry.Key);
    80.             }
    81.             return keys;
    82.         }
    83.  
    84.         private static void WriteHeadLine(StreamWriter writer, string collectionName, List<CultureInfo> cultures)
    85.         {
    86.             string line = $"{collectionName}{_CSV_SEPARATOR_}Keys\\locales";
    87.             foreach (CultureInfo info in cultures)
    88.             {
    89.                 line = $"{line}{_CSV_SEPARATOR_}{info.ToString()}";
    90.             }
    91.             writer.WriteLine(line);
    92.         }
    93.  
    94.         private static void WriteKeysLines(StreamWriter writer, AssetTableCollection collection, List<string> keys)
    95.         {
    96.             foreach (string key in keys)
    97.             {
    98.                 string line = $"{collection.TableName}{_CSV_SEPARATOR_}{key}";
    99.                 foreach (LocalizedTable table in collection.Tables)
    100.                 {
    101.                     line = $"{line}{_CSV_SEPARATOR_}{((StringTable)table).GetEntry(key).Value}";
    102.                 }
    103.                 writer.WriteLine(line);
    104.             }
    105.         }
    106.     }
    107.  
    108.     private static class CSVImporter
    109.     {
    110.         private const int _INDEX_CollectionName_ = 0;
    111.         private const int _INDEX_Key_ = 1;
    112.         private const int _INDEX_ContentStart_ = 2;
    113.  
    114.         public static void FromCSV(string path)
    115.         {
    116.             using (var reader = new StreamReader(path))
    117.             {
    118.                 CultureInfo[] cultures = GetCulturesFromHeadLine(reader);
    119.  
    120.                 string line = string.Empty;
    121.                 while ((line = reader.ReadLine()) != null)
    122.                 {
    123.                     string[] content = line.Split(_CSV_SEPARATOR_);
    124.                     string collectionName = content[_INDEX_CollectionName_];
    125.                     string key = content[_INDEX_Key_];
    126.                     for (int i = _INDEX_ContentStart_; i < content.Length; i++)
    127.                     {
    128.                         CultureInfo cellCultureInfo = cultures[i - _INDEX_ContentStart_];
    129.                         LocalizedTable table = FindLocalizedTable(collectionName, cellCultureInfo);
    130.                         SetKey((StringTable)table, key, content[i]);
    131.                         EditorUtility.SetDirty(table);
    132.                     }
    133.                 }
    134.             }
    135.  
    136.             AssetDatabase.SaveAssets();
    137.         }
    138.  
    139.         private static CultureInfo[] GetCulturesFromHeadLine(StreamReader reader)
    140.         {
    141.             List<CultureInfo> cultures = new List<CultureInfo>();
    142.             string[] content = reader.ReadLine().Split(_CSV_SEPARATOR_);
    143.             for (int i = 2; i < content.Length; i++)
    144.             {
    145.                 cultures.Add(new CultureInfo(content[i], false));
    146.             }
    147.             return cultures.ToArray();
    148.         }
    149.  
    150.         private static void SetKey(StringTable table, string key, string value)
    151.         {
    152.             StringTableEntry entry = table.GetEntry(key);
    153.             if (entry.Value != value)
    154.             {
    155.                 Debug.Log($"{table.TableName} - {key} : {entry.Value} => {value}");
    156.                 entry.Value = value;
    157.             }
    158.         }
    159.  
    160.         private static LocalizedTable FindLocalizedTable(string collection_name, CultureInfo locale)
    161.         {
    162.             LocalizedTable result = null;
    163.             foreach (AssetTableCollection collection in LocalizationEditorSettings.GetAssetTablesCollection<StringTable>())
    164.             {
    165.                 if (collection.TableName.Equals(collection_name))
    166.                 {
    167.                     foreach (LocalizedTable table in collection.Tables)
    168.                     {
    169.                         if (table.LocaleIdentifier.CultureInfo.Equals(locale))
    170.                         {
    171.                             result = table;
    172.                             break;
    173.                         }
    174.                     }
    175.                 }
    176.                 if (result != null) { break; }
    177.             }
    178.             if (result == null)
    179.             {
    180.                 Debug.Log($"{collection_name} - {locale} not found !");
    181.             }
    182.             return result;
    183.         }
    184.     }
    185. }
    186.  
     
    karl_jones likes this.
  7. jamesor

    jamesor

    Joined:
    Jan 1, 2020
    Posts:
    11
    I've been using Google Sheets and wrote this little importer which is getting the job done. I welcome any feedback on how I can improve it.

    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4. using UnityEngine.Localization.Tables;
    5. using UnityEditor;
    6.  
    7. namespace AE
    8. {
    9.     [CreateAssetMenu(fileName = "New String Importer", menuName = "Localization/String Importer")]
    10.     public class StringImporter : ScriptableObject
    11.     {
    12.         [Header("String Tables")]
    13.         public SharedTableData SharedTable;
    14.         public StringTable[] StringTables;
    15.         [Header("Data Source Files (Tab Delimited)")]
    16.         public TextAsset TabDelimitedFile;
    17.         public void Import()
    18.         {
    19.             if (SharedTable == null)
    20.             {
    21.                 Debug.LogError("A SharedTableData reference is required.");
    22.                 return;
    23.             }
    24.             if (StringTables == null || StringTables.Length == 0)
    25.             {
    26.                 Debug.LogError("One or more StringTable references are required.");
    27.                 return;
    28.             }
    29.             if (TabDelimitedFile == null)
    30.             {
    31.                 Debug.LogError("A tab delimited file reference is required.");
    32.                 return;
    33.             }
    34.             Dictionary<string, int> langNames = new Dictionary<string, int>();
    35.             for (var i = 0; i < StringTables.Length; i++)
    36.             {
    37.                 langNames.Add(StringTables[i].LocaleIdentifier.ToString(), i);
    38.             }
    39.             if (langNames.Count == 0)
    40.             {
    41.                 Debug.LogError("At least one language needs to be configured in LocalizationSettings.");
    42.                 return;
    43.             }
    44.             Dictionary<string, int> fieldNames = new Dictionary<string, int>();
    45.             var splitFile = new string[] { "\r\n", "\r", "\n" };
    46.             var splitLine = new string[] { "\t" };
    47.             var lines = TabDelimitedFile.text.Split(splitFile, StringSplitOptions.None);
    48.             var isFieldNameRow = true;
    49.             // We need at minimum the Field Names Row and a Data Row
    50.             if (lines.Length < 2)
    51.             {
    52.                 Debug.LogError("The tab delimited file needs to contain at minimum a field name row and a data row.");
    53.                 return;
    54.             }
    55.             foreach (var lang in langNames)
    56.             {
    57.                 StringTables[lang.Value].Clear();
    58.                 Undo.RecordObject(StringTables[lang.Value], "Changed translated text");
    59.                 EditorUtility.SetDirty(StringTables[lang.Value]);
    60.             }
    61.             if (SharedTable != null)
    62.             {
    63.                 Undo.RecordObject(SharedTable, "Changed translated text");
    64.                 EditorUtility.SetDirty(SharedTable);
    65.             }
    66.             for (uint i = 0; i < lines.Length; i++)
    67.             {
    68.                 var line = lines[i].Split(splitLine, StringSplitOptions.None);
    69.                 if (isFieldNameRow)
    70.                 {
    71.                     for (int j = 0; j < line.Length; j++)
    72.                     {
    73.                         fieldNames.Add(line[j], j);
    74.                     }
    75.                     isFieldNameRow = false;
    76.                 }
    77.                 else if (line[fieldNames["key"]] == "!!_IGNORE_!!")
    78.                 {
    79.                     // Ignore this row because it just contains a template forumla for Google Translate
    80.                 }
    81.                 else
    82.                 {
    83.                     foreach (var lang in langNames)
    84.                     {
    85.                         StringTables[lang.Value].AddEntry(line[fieldNames["key"]], line[fieldNames[lang.Key]]);
    86.                     }
    87.                 }
    88.             }
    89.             Debug.Log("Finished importing text");
    90.         }
    91.     }
    92.  
    93.     [CustomEditor(typeof(StringImporter))]
    94.     public class TestScriptableEditor : Editor
    95.     {
    96.         public override void OnInspectorGUI()
    97.         {
    98.             base.OnInspectorGUI();
    99.             var script = (StringImporter)target;
    100.  
    101.             if (GUILayout.Button("Import Language Strings", GUILayout.Height(40)))
    102.             {
    103.                 script.Import();
    104.             }
    105.         }
    106.     }
    107. }
    108.  
    I export my data from Google Sheets as a Tab Delimited File and bring it in as a TextAsset into an Instance of the ImporterScript. Each set of tables has it's own Importer instance. The first row uses language names like "English(en)" and "Spanish(es)" to match up with the available locals I defined iin my Localization Settings.

    importer.png
    sheets.png
     
    Last edited: May 28, 2020
    krasner likes this.
  8. krasner

    krasner

    Joined:
    Sep 9, 2014
    Posts:
    7
    I grabbed the above script and improved it a bit. It uses CSV (comma separated) export because that export preserves formatting such as newlines. Hope this helps anyone!
    Code (CSharp):
    1.  
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4. using UnityEngine.Localization.Tables;
    5. using UnityEditor;
    6. using System;
    7. using System.Text.RegularExpressions;
    8.  
    9. [CreateAssetMenu(fileName = "New String Importer", menuName = "Localization/String Importer")]
    10. public class StringImporter : ScriptableObject
    11. {
    12.     [Header("String Tables")]
    13.     public SharedTableData SharedTable;
    14.     public StringTable[] StringTables;
    15.     [Header("Data Source Files (Comma Delimited)")]
    16.     public TextAsset CommaSeparatedFile;
    17.     public void Import()
    18.     {
    19.         if (SharedTable == null)
    20.         {
    21.             Debug.LogError("A SharedTableData reference is required.");
    22.             return;
    23.         }
    24.         if (StringTables == null || StringTables.Length == 0)
    25.         {
    26.             Debug.LogError("One or more StringTable references are required.");
    27.             return;
    28.         }
    29.         if (CommaSeparatedFile == null)
    30.         {
    31.             Debug.LogError("A comma delimited file reference is required.");
    32.             return;
    33.         }
    34.         Dictionary<string, int> langNames = new Dictionary<string, int>();
    35.         for (var i = 0; i < StringTables.Length; i++)
    36.         {
    37.             langNames.Add(StringTables[i].LocaleIdentifier.ToString(), i);
    38.         }
    39.         if (langNames.Count == 0)
    40.         {
    41.             Debug.LogError("At least one language needs to be configured in LocalizationSettings.");
    42.             return;
    43.         }
    44.         Dictionary<string, int> fieldNames = new Dictionary<string, int>();
    45.  
    46.         string pattern = "\n(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)";
    47.  
    48.         var lines = Regex.Split(CommaSeparatedFile.text, pattern);
    49.  
    50.  
    51.        
    52.         var isFieldNameRow = true;
    53.         // We need at minimum the Field Names Row and a Data Row
    54.         if (lines.Length < 2)
    55.         {
    56.             Debug.LogError("The tab delimited file needs to contain at minimum a field name row and a data row.");
    57.             return;
    58.         }
    59.         foreach (var lang in langNames)
    60.         {
    61.             StringTables[lang.Value].Clear();
    62.             Undo.RecordObject(StringTables[lang.Value], "Changed translated text");
    63.             EditorUtility.SetDirty(StringTables[lang.Value]);
    64.         }
    65.         if (SharedTable != null)
    66.         {
    67.             Undo.RecordObject(SharedTable, "Changed translated text");
    68.             EditorUtility.SetDirty(SharedTable);
    69.         }
    70.         for (uint i = 0; i < lines.Length; i++)
    71.         {
    72.             var line = Regex.Split(lines[i], ",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)");
    73.            
    74.            
    75.             if (isFieldNameRow)
    76.             {
    77.                 for (int j = 0; j < line.Length; j++)
    78.                 {
    79.                     Debug.Log(line[j] + "," + j);
    80.          
    81.                     fieldNames.Add(line[j], j);
    82.                 }
    83.                 isFieldNameRow = false;
    84.             }
    85.             else
    86.             {
    87.                 foreach (var lang in langNames) //hax for Japanese loc name since ofr some reason it doesn't match??
    88.                 {
    89.                     if (!fieldNames.ContainsKey(lang.Key))
    90.                     {
    91.                         foreach(var n in fieldNames)
    92.                         {
    93.                             if(n.Key.Contains(lang.Key))
    94.                             {
    95.                                 StringTables[lang.Value].AddEntry(line[fieldNames["Key"]], line[fieldNames[n.Key]].Trim('"'));
    96.                             }
    97.                         }
    98.                     }
    99.                     else
    100.                         StringTables[lang.Value].AddEntry(line[fieldNames["Key"]], line[fieldNames[lang.Key]].Trim('"'));
    101.  
    102.                 }
    103.             }
    104.         }
    105.         Debug.Log("Finished importing text");
    106.     }
    107. }
    108.  
    109. [CustomEditor(typeof(StringImporter))]
    110. public class TestScriptableEditor : Editor
    111. {
    112.     public override void OnInspectorGUI()
    113.     {
    114.         base.OnInspectorGUI();
    115.         var script = (StringImporter)target;
    116.  
    117.         if (GUILayout.Button("Import Language Strings", GUILayout.Height(40)))
    118.         {
    119.             script.Import();
    120.         }
    121.     }
    122. }
    123.  
     
    jamesor and karl_jones like this.
  9. mistergreen2016

    mistergreen2016

    Joined:
    Dec 4, 2016
    Posts:
    226
    The CSV importer worked great but took me a long time to figure out that the column language title MUST match.
    example: English(en) NOT English (en) - No space
     
    Last edited: Jun 19, 2020
    pcorralf likes this.
  10. Only4gamers

    Only4gamers

    Joined:
    Nov 8, 2019
    Posts:
    327
    Is it now possible to export my all translations to excel?
     
  11. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    If you use the Google sheets Integration then you could export via Google sheets. We do have plans to add support for excel directly in the future.
     
    Only4gamers likes this.
  12. ptuananh196

    ptuananh196

    Joined:
    Mar 16, 2020
    Posts:
    4
    Hi, is there a plan for csv import/export in runtime without depending on UnityEditor in the future?
     
    Last edited: Jul 13, 2021
  13. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    ptuananh196 likes this.
  14. Bankler

    Bankler

    Joined:
    Apr 10, 2012
    Posts:
    66
    My use case is that I have localization for a couple of different languages. Strings only, not assets.

    I want my game to download an updated version of the string database when the game starts. The updated version can contain new ENTRIES, but not new full string tables (which is why I'm not sure if the link above applies to my problem). I use Playfab, so my gut feeling approach was to check for an updated csv on the server and import it to the string database at run time. After having read all the threads and docs I can find I realize it might not be as straight-forward as I initially assumed. I kind of expected a LocalizationSettings.ImportCsvAsString(string csvAsText) or something like that.

    Then there's the Addressables stuff, which I understand potentially could accomplish what I'm looking for?

    What would be the best and most straight-forward approach? I was wondering if you can point me in the right direction, preferably like you're talking to a 5 year old kid, because my head is spinning here. :confused:o_O
     
  15. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    Using Addressables would be the best way but it does require you to host your asset bundles on a cloud.
    https://docs.unity3d.com/Packages/c.../manual/AddressableAssetsHostingServices.html
    https://docs.unity3d.com/Packages/com.unity.addressables@1.19/manual/AddressablesCCD.html
    https://docs.unity3d.com/Packages/com.unity.addressables@1.19/manual/ContentUpdateWorkflow.html

    If you are downloading a CSV file to update an existing table only then you have 2 options:

    1. Something like this https://forum.unity.com/threads/creating-stringtable-at-runtime.999439/

    2. A very simple alternative would be to load the StringTable at the start and patch the changes across. Then hold onto the table so that it is not unloaded and the changes are not lost. You would need to do this every time the application starts.
    Something like

    Code (csharp):
    1. var table = LocalizationSettings.StringDatabase.LoadStringTableAsync("my Table");
    2. Addressables.ResourceManager.Aquire(table); // Prevent the table being unloaded.
    3.  
    4. // Apply the CSV changes
     
    Bankler likes this.
  16. Bankler

    Bankler

    Joined:
    Apr 10, 2012
    Posts:
    66
    Thanks a lot for the advice. Much appreciated!

    So I decided to follow your advice and go for the proper solution, using addressables. As a first step, I'm trying to accomplish the editor hosting. I followed the AddressableAssetsHostingServices.html guide as closely as I can, but it fails to download the asset bundle. Considering my inexperience with asset bundles and addressables, I'm almost certain I'm just doing some stupid simple mistake.

    Not sure if I might be derailing this thread here. And I guess it's more related to addressables than localization. But I figured it could be relevant for other users in the same position?

    The game reaches the ip (which it doesn't if I disable the service, obviously). But it looks like it doesn't find the file where it's looking. I'm thinking it might be some kind of error with the paths? One thing I'm concerned about, is that the guide suggests putting "HostedData" in the Local and Remote BuildPaths, but not in the Load paths. But I don't know, at the same time I get the impression that the HostedData folder should become the "root", to which ip+port points, and hence, should be excluded from the LoadPaths (just like it is setup here, and in the guide).

    If you get the chance, please let me know if you see anything funky in my config. Thanks in advance!
     

    Attached Files:

  17. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
    Theres a dedicated forum for Addressables where I think you will have more luck https://forum.unity.com/forums/addressables.156/
    I did manage to get local hosting working but I didnt encounter these issues so cant really say what it could be.
     
  18. Bankler

    Bankler

    Joined:
    Apr 10, 2012
    Posts:
    66
    Okay, thank you. I'll copy-pasted my question there, here's the url to that post if anyone else comes this way looking for the same thing. https://forum.unity.com/threads/failing-to-download-from-editor-host.1173260/

    @karl_jones Your fast replies are awesome. Having someone helping you along when you're stuck and demoralized is absolutely golden. Thanks!
     
    karl_jones likes this.
  19. cameronjohnson-mz

    cameronjohnson-mz

    Joined:
    Jan 17, 2023
    Posts:
    25
    @karl_jones Are there native scripts or API commands to import and export the String Asset Table as a CSV file from a Unity project?

    I want to automate our localization pipeline where a new key is added to the table, the data gets committed into Github and then use a script to calls an export command that converts the table into CSV format and spits out the file.

    I've searched a lot of docs and forums, but I haven't found a solution yet.
     
  20. karl_jones

    karl_jones

    Unity Technologies

    Joined:
    May 5, 2015
    Posts:
    8,282
  21. cameronjohnson-mz

    cameronjohnson-mz

    Joined:
    Jan 17, 2023
    Posts:
    25
    karl_jones likes this.