using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEditor.Localization.Addressables; using UnityEngine; using UnityEngine.Localization; using UnityEngine.Localization.Pseudo; using UnityEngine.Localization.Settings; using UnityEngine.Localization.Tables; using Object = UnityEngine.Object; namespace UnityEditor.Localization { /// /// Provides methods for configuring Localization settings including tables and Locales. /// public class LocalizationEditorSettings { static readonly char[] k_UnityInvalidFileNameChars = { '/', '?', '<', '>', '\\', ':', '|', '\"' }; static readonly IEnumerable k_InvalidFileNameChars = Path.GetInvalidFileNameChars().Concat(k_UnityInvalidFileNameChars); internal const string k_GameViewPref = "Localization-ShowLocaleMenuInGameView"; internal const string k_StringPicker = "Localization-UseSearchStringPicker"; internal const string k_AssetPicker = "Localization-UseSearchAssetPicker"; internal const string k_TableRefMethod = "Localization-TableRefMethod"; internal const string k_EntryRefMethod = "Localization-EntryRefMethod"; static LocalizationEditorSettings s_Instance; // Cached searches to help performance. ReadOnlyCollection m_ProjectLocales; ReadOnlyCollection m_ProjectPseudoLocales; // Allows for overriding the default behavior, used for testing. internal static LocalizationEditorSettings Instance { get => s_Instance ?? (s_Instance = new LocalizationEditorSettings()); set => s_Instance = value; } internal virtual LocalizationTableCollectionCache TableCollectionCache { get; set; } = new LocalizationTableCollectionCache(); /// /// The used for this project and available in the player and editor. /// public static LocalizationSettings ActiveLocalizationSettings { get => Instance.ActiveLocalizationSettingsInternal; set => Instance.ActiveLocalizationSettingsInternal = value; } /// /// During play mode, in the editor a menu can be shown to allow for quickly changing the . /// public static bool ShowLocaleMenuInGameView { get => EditorPrefs.GetBool(k_GameViewPref, true); set => EditorPrefs.SetBool(k_GameViewPref, value); } /// /// When the advanced Unity Search picker will be used when selecting table entries. /// When the tree view picker will be used. /// public static bool UseLocalizedStringSearchPicker { get => EditorPrefs.GetBool(k_StringPicker, true); set => EditorPrefs.SetBool(k_StringPicker, value); } /// /// When the advanced Unity Search picker will be used when selecting table entries. /// When the tree view picker will be used. /// public static bool UseLocalizedAssetSearchPicker { get => EditorPrefs.GetBool(k_AssetPicker, true); set => EditorPrefs.SetBool(k_AssetPicker, value); } /// /// Adds a reference to a table in the Editor. /// public static TableReferenceMethod TableReferenceMethod { get => (TableReferenceMethod)EditorPrefs.GetInt(k_TableRefMethod, (int)TableReferenceMethod.Guid); set => EditorPrefs.SetInt(k_TableRefMethod, (int)value); } /// /// Adds a reference to a table entry in the Editor. /// public static EntryReferenceMethod EntryReferenceMethod { get => (EntryReferenceMethod)EditorPrefs.GetInt(k_EntryRefMethod, (int)EntryReferenceMethod.Id); set => EditorPrefs.SetInt(k_EntryRefMethod, (int)value); } /// /// Localization modification events that can be used when building editor components. /// public static LocalizationEditorEvents EditorEvents { get; internal set; } = new LocalizationEditorEvents(); internal LocalizationEditorSettings() { EditorEvents.LocaleSortOrderChanged += (sender, locale) => SortLocales(); Undo.undoRedoPerformed += UndoRedoPerformed; } ~LocalizationEditorSettings() { Undo.undoRedoPerformed -= UndoRedoPerformed; } /// /// Add the Locale so that it can be used by the Localization system. /// /// /// This shows how to create a Locale and add it to the project. /// /// /// The to add to the project so it can be used by the Localization system. /// Used to indicate if an Undo operation should be created. public static void AddLocale(Locale locale, bool createUndo = false) => Instance.AddLocaleInternal(locale, createUndo); /// /// Removes the locale from the Localization system. /// /// /// This shows how to remove a Locale from the project. /// /// /// The to remove so that it is no longer used by the Localization system. /// Used to indicate if an Undo operation should be created. public static void RemoveLocale(Locale locale, bool createUndo = false) => Instance.RemoveLocaleInternal(locale, createUndo); /// /// Returns all that are part of the Localization system and will be included in the player. /// To Add Locales use and to remove them. /// Note this does not include which can be retrieved by using . /// /// /// This example prints the names of the Locales. /// /// /// A collection of all Locales in the project. public static ReadOnlyCollection GetLocales() => Instance.GetLocalesInternal(); /// /// Returns all that are part of the Localization system and will be included in the player. /// /// public static ReadOnlyCollection GetPseudoLocales() => Instance.GetPseudoLocalesInternal(); /// /// Returns the locale that matches the "/> in the project. /// /// /// /// This example shows how to find a using a [SystemLanguage](https://docs.unity3d.com/ScriptReference/SystemLanguage.html) or code. /// /// /// The found or null if one could not be found. public static Locale GetLocale(LocaleIdentifier localeId) => Instance.GetLocaleInternal(localeId.Code); /// /// Returns all that are in the project. /// /// /// This example shows how to print out the contents of all the in the project. /// /// /// public static ReadOnlyCollection GetStringTableCollections() => Instance.GetStringTableCollectionsInternal(); /// /// Returns a with the matching . /// /// /// /// This example shows how to update a collection by adding support for a new Locale. /// /// /// Found collection or null if one could not be found. public static StringTableCollection GetStringTableCollection(TableReference tableNameOrGuid) => Instance.TableCollectionCache.FindStringTableCollection(tableNameOrGuid); /// /// Returns all assets that are in the project. /// /// /// This example shows how to print out the contents of all the in the project. /// /// /// public static ReadOnlyCollection GetAssetTableCollections() => Instance.TableCollectionCache.AssetTableCollections.AsReadOnly(); /// /// Returns a with the matching . /// /// /// /// This example shows how to update a collection by adding a new localized asset. /// /// /// Found collection or null if one could not be found. public static AssetTableCollection GetAssetTableCollection(TableReference tableNameOrGuid) => Instance.TableCollectionCache.FindAssetTableCollection(tableNameOrGuid); /// /// Returns the that the table is part of or null if the table has no collection. /// /// The table to find the collection for. /// The found collection or null if one could not be found. public static LocalizationTableCollection GetCollectionFromTable(LocalizationTable table) => Instance.TableCollectionCache.FindCollectionForTable(table); /// /// Returns the that the is part of or null if one could not be found. /// /// The shared table data to match against a collection. /// The found collection or null if one could not be found. public static LocalizationTableCollection GetCollectionForSharedTableData(SharedTableData sharedTableData) => Instance.TableCollectionCache.FindCollectionForSharedTableData(sharedTableData); /// /// If a table does not belong to a then it is considered to be loose, it has no parent collection and will be ignored. /// This returns all loose tables that use the same , they could then be converted into a using . /// /// /// public static void FindLooseStringTablesUsingSharedTableData(SharedTableData sharedTableData, IList foundTables) => Instance.TableCollectionCache.FindLooseTablesUsingSharedTableData(sharedTableData, foundTables); /// /// Creates a or from the provided loose tables. /// /// Tables to create the collection from. All tables must be of the same type. /// The path to save the new assets to. /// The created or . public static LocalizationTableCollection CreateCollectionFromLooseTables(IList looseTables, string path) => Instance.CreateCollectionFromLooseTablesInternal(looseTables, path); /// /// Creates a using the project Locales. /// /// /// This example shows how to create a new and add some English values. /// /// /// The name of the new collection. Cannot be blank or whitespace, cannot contain invalid filename characters, and cannot contain "[]". /// The directory to save the generated assets, must be in the project Assets directory. /// The created collection. public static StringTableCollection CreateStringTableCollection(string tableName, string assetDirectory) => CreateStringTableCollection(tableName, assetDirectory, GetLocales()); /// /// Creates a using the provided Locales. /// /// /// This example shows how to create a new which contains an English and Japanese . /// /// /// The name of the new collection. Cannot be blank or whitespace, cannot contain invalid filename characters, and cannot contain "[]". /// The directory to save the generated assets, must be in the project Assets directory. /// The locales to generate the collection with. A will be created for each Locale. /// The created collection. public static StringTableCollection CreateStringTableCollection(string tableName, string assetDirectory, IList selectedLocales) => Instance.CreateCollection(typeof(StringTableCollection), tableName, assetDirectory, selectedLocales) as StringTableCollection; /// /// Creates a using the project Locales. /// /// /// This example shows how to update a collection by adding a new localized asset. /// /// /// The Table Collection Name to use. /// The directory to store the generated assets. /// The created collection. public static AssetTableCollection CreateAssetTableCollection(string tableName, string assetDirectory) => CreateAssetTableCollection(tableName, assetDirectory, GetLocales()); /// /// Creates a using the provided Locales. /// /// The name of the new collection. Cannot be blank or whitespace, cannot contain invalid filename characters, and cannot contain "[]". /// The directory to store the generated assets. /// The locales to generate the collection with. A will be created for each Locale /// The created collection. public static AssetTableCollection CreateAssetTableCollection(string tableName, string assetDirectory, IList selectedLocales) => Instance.CreateCollection(typeof(AssetTableCollection), tableName, assetDirectory, selectedLocales) as AssetTableCollection; /// /// Adds or Remove the preload flag for the selected table. /// /// /// This example shows how to set the preload flag for a single collection. /// /// /// The table to mark as preload. /// ifd the table should be preloaded or if it should be loaded on demand. /// Should an Undo record be created? public static void SetPreloadTableFlag(LocalizationTable table, bool preload, bool createUndo = false) { Instance.SetPreloadTableInternal(table, preload, createUndo); } /// /// Returns if the table is marked for preloading. /// /// /// This example shows how to query if a table is marked as preload. /// /// /// The table to query. /// if preloading is enable otherwise . public static bool GetPreloadTableFlag(LocalizationTable table) { // TODO: We could just use the instance id so we dont need to load the whole table return Instance.GetPreloadTableFlagInternal(table); } /// /// Returns the that contains a table entry with the closest match to the provided text. /// Uses the Levenshtein distance method. /// /// /// [Obsolete("FindSimilarKey will be removed in the future, please use Unity Search. See TableEntrySearchData class for further details.")] public static (StringTableCollection collection, SharedTableData.SharedTableEntry entry, int matchDistance) FindSimilarKey(string keyName) { throw new NotSupportedException("FindSimilarKey is obsolete. please use Unity Search instead."); } internal static void RefreshEditorPreview() { // Only update the preview in edit mode (LOC-1025) if (LocalizationSettings.Instance.IsPlayingOrWillChangePlaymode) return; if (ActiveLocalizationSettings != null && ActiveLocalizationSettings.GetSelectedLocale() != null) { LocalizationPropertyDriver.UnregisterProperties(); VariantsPropertyDriver.UnregisterProperties(); ActiveLocalizationSettings.SendLocaleChangedEvents(LocalizationSettings.SelectedLocale); } } internal string GetUniqueTableCollectionName(Type collectionType, string name) { int suffix = 1; var nameToTest = name; while (true) { if (TableCollectionCache.FindTableCollection(collectionType, nameToTest) == null) return nameToTest; nameToTest = $"{name} {suffix}"; suffix++; } } internal virtual LocalizationTableCollection CreateCollection(Type collectionType, string tableName, string assetDirectory, IList selectedLocales) { if (!typeof(LocalizationTableCollection).IsAssignableFrom(collectionType)) throw new ArgumentException($"{collectionType.Name} Must be derived from {nameof(LocalizationTableCollection)}", nameof(collectionType)); if (string.IsNullOrEmpty(assetDirectory)) throw new ArgumentException("Must not be null or empty.", nameof(assetDirectory)); var tableNameError = IsTableNameValid(collectionType, tableName); if (tableNameError != null) { throw new ArgumentException(tableNameError, nameof(tableName)); } var collection = ScriptableObject.CreateInstance(collectionType) as LocalizationTableCollection; AssetDatabase.StartAssetEditing(); // TODO: Check that no tables already exist with the same name, locale and type. var relativePath = PathHelper.MakePathRelative(assetDirectory); Directory.CreateDirectory(relativePath); var sharedDataPath = Path.Combine(relativePath, AddressHelper.GetSharedTableAddress(tableName) + ".asset"); var sharedTableData = ScriptableObject.CreateInstance(); sharedTableData.TableCollectionName = tableName; CreateAsset(sharedTableData, sharedDataPath); collection.SharedData = sharedTableData; collection.AddSharedTableDataToAddressables(); // Extract the SharedTableData Guid and assign it so we can use it as a unique id for the table collection name. var sharedDataGuid = GetAssetGuid(sharedTableData); sharedTableData.TableCollectionNameGuid = Guid.Parse(sharedDataGuid); EditorUtility.SetDirty(sharedTableData); // We need to set it dirty so the change to TableCollectionNameGuid is saved. if (selectedLocales?.Count > 0) { var createdTables = new List(selectedLocales.Count); foreach (var locale in selectedLocales) { var table = ScriptableObject.CreateInstance(collection.TableType) as LocalizationTable; table.SharedData = sharedTableData; table.LocaleIdentifier = locale.Identifier; table.name = AddressHelper.GetTableAddress(tableName, locale.Identifier); createdTables.Add(table); } for (int i = 0; i < createdTables.Count; ++i) { var tbl = createdTables[i]; var assetPath = Path.Combine(relativePath, tbl.name + ".asset"); assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath); CreateAsset(tbl, assetPath); collection.AddTable(tbl, postEvent: false); } } // Save the collection collection.name = tableName; var collectionPath = Path.Combine(relativePath, collection.name + ".asset"); CreateAsset(collection, collectionPath); AssetDatabase.StopAssetEditing(); return collection; } internal virtual LocalizationTableCollection CreateCollectionFromLooseTablesInternal(IList looseTables, string path) { if (looseTables == null || looseTables.Count == 0) return null; var isStringTable = looseTables[0] is StringTable; var collectionType = isStringTable ? typeof(StringTableCollection) : typeof(AssetTableCollection); var collection = ScriptableObject.CreateInstance(collectionType) as LocalizationTableCollection; collection.SharedData = looseTables[0].SharedData; foreach (var table in looseTables) { if (table.SharedData != collection.SharedData) { Debug.LogError($"Table {table.name} does not share the same Shared Table Data and can not be part of the new collection", table); continue; } collection.AddTable(table, postEvent: false); // Don't post the event, we will send the Collection added only event } var relativePath = PathHelper.MakePathRelative(path); CreateAsset(collection, relativePath); EditorEvents.RaiseCollectionAdded(collection); return collection; } internal virtual LocalizationSettings ActiveLocalizationSettingsInternal { get { EditorBuildSettings.TryGetConfigObject(LocalizationSettings.ConfigName, out LocalizationSettings settings); return settings; } set { if (value == null) { EditorBuildSettings.RemoveConfigObject(LocalizationSettings.ConfigName); } else { EditorBuildSettings.AddConfigObject(LocalizationSettings.ConfigName, value, true); } } } internal virtual AddressableAssetSettings GetAddressableAssetSettings(bool create) { var settings = AddressableAssetSettingsDefaultObject.GetSettings(create); if (settings != null) return settings; // By default Addressables wont return the settings if updating or compiling. This causes issues for us, especially if we are trying to get the Locales. // We will just ignore this state and try to get the settings regardless. if (EditorApplication.isUpdating || EditorApplication.isCompiling) { // Legacy support if (EditorBuildSettings.TryGetConfigObject(AddressableAssetSettingsDefaultObject.kDefaultConfigAssetName, out settings)) { return settings; } AddressableAssetSettingsDefaultObject so; if (EditorBuildSettings.TryGetConfigObject(AddressableAssetSettingsDefaultObject.kDefaultConfigObjectName, out so)) { // Extract the guid var serializedObject = new SerializedObject(so); var guid = serializedObject.FindProperty("m_AddressableAssetSettingsGuid")?.stringValue; if (!string.IsNullOrEmpty(guid)) { var path = AssetDatabase.GUIDToAssetPath(guid); return AssetDatabase.LoadAssetAtPath(path); } } } return null; } internal virtual AddressableAssetEntry GetAssetEntry(Object asset) => GetAssetEntry(asset.GetInstanceID()); internal virtual AddressableAssetEntry GetAssetEntry(int instanceId) { var settings = GetAddressableAssetSettings(false); if (settings == null) return null; var guid = GetAssetGuid(instanceId); return settings.FindAssetEntry(guid); } internal virtual string FindUniqueAssetAddress(string address) { var aaSettings = GetAddressableAssetSettings(false); if (aaSettings == null) return address; var validAddress = address; var index = 1; var foundExisting = true; while (foundExisting) { if (index > 1000) { Debug.LogError("Unable to create valid address for new Addressable Asset."); return address; } foundExisting = false; foreach (var g in aaSettings.groups) { if (g.Name == validAddress) { foundExisting = true; validAddress = address + index; index++; break; } } } return validAddress; } internal virtual void AddLocaleInternal(Locale locale, bool createUndo) { if (locale == null) throw new ArgumentNullException(nameof(locale)); if (!EditorUtility.IsPersistent(locale)) throw new AssetNotPersistentException(locale); var aaSettings = GetAddressableAssetSettings(true); if (aaSettings == null) return; using (new UndoScope("Add Locale", createUndo)) { var assetEntry = AddressableGroupRules.AddLocaleToGroup(locale, aaSettings, createUndo); assetEntry.address = locale.LocaleName; // Clear the locales cache. m_ProjectLocales = null; m_ProjectPseudoLocales = null; if (!LocalizationSettings.Instance.IsPlayingOrWillChangePlaymode) LocalizationSettings.Instance.ResetState(); if (!assetEntry.labels.Contains(LocalizationSettings.LocaleLabel)) { if (createUndo) Undo.RecordObjects(new Object[] { aaSettings, assetEntry.parentGroup }, "Add locale"); assetEntry.SetLabel(LocalizationSettings.LocaleLabel, true, true); EditorEvents.RaiseLocaleAdded(locale); } } } internal virtual void RemoveLocaleInternal(Locale locale, bool createUndo) { // Clear the locale cache m_ProjectLocales = null; m_ProjectPseudoLocales = null; if (!LocalizationSettings.Instance) LocalizationSettings.Instance.ResetState(); var aaSettings = GetAddressableAssetSettings(false); if (aaSettings == null) return; var localeAssetEntry = GetAssetEntry(locale); if (localeAssetEntry == null) return; using (new UndoScope("Remove locale", createUndo)) { if (createUndo) Undo.RecordObjects(new Object[] { aaSettings, localeAssetEntry.parentGroup }, "Remove locale"); aaSettings.RemoveAssetEntry(localeAssetEntry.guid); EditorEvents.RaiseLocaleRemoved(locale); } } internal virtual ReadOnlyCollection GetLocalesInternal() { if (m_ProjectLocales != null) return m_ProjectLocales; var foundLocales = new List(); var aaSettings = GetAddressableAssetSettings(false); if (aaSettings == null) return new ReadOnlyCollection(foundLocales); var foundAssets = new List(); aaSettings.GetAllAssets(foundAssets, false, group => group != null, entry => { return entry.labels.Contains(LocalizationSettings.LocaleLabel); }); foreach (var localeAddressable in foundAssets) { if (!string.IsNullOrEmpty(localeAddressable.guid)) { var locale = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(localeAddressable.guid)); if (locale != null && !(locale is PseudoLocale)) // Dont include Pseudo locales. foundLocales.Add(locale); } } foundLocales.Sort(); m_ProjectLocales = new ReadOnlyCollection(foundLocales); return m_ProjectLocales; } internal virtual ReadOnlyCollection GetPseudoLocalesInternal() { if (m_ProjectPseudoLocales == null) CollectProjectLocales(); return m_ProjectPseudoLocales; } void CollectProjectLocales() { var foundLocales = new List(); var foundPseudoLocales = new List(); var aaSettings = GetAddressableAssetSettings(false); if (aaSettings != null) { var foundAssets = new List(); aaSettings.GetAllAssets(foundAssets, false, group => group != null, entry => { return entry.labels.Contains(LocalizationSettings.LocaleLabel); }); foreach (var localeAddressable in foundAssets) { if (localeAddressable.MainAsset != null && localeAddressable.MainAsset is Locale locale) { if (locale is PseudoLocale pseudoLocale) { foundPseudoLocales.Add(pseudoLocale); } else { foundLocales.Add(locale); } } } } foundLocales.Sort(); foundPseudoLocales.Sort(); m_ProjectLocales = foundLocales.AsReadOnly(); m_ProjectPseudoLocales = foundPseudoLocales.AsReadOnly(); } internal virtual ReadOnlyCollection GetStringTableCollectionsInternal() => Instance.TableCollectionCache.StringTableCollections.AsReadOnly(); Locale GetLocaleInternal(string code) => GetLocalesInternal()?.FirstOrDefault(loc => loc.Identifier.Code == code); void SortLocales() { if (m_ProjectLocales != null) { var localesList = m_ProjectLocales.ToList(); localesList.Sort(); m_ProjectLocales = localesList.AsReadOnly(); } if (m_ProjectPseudoLocales != null) { var pseudoLocalesList = m_ProjectPseudoLocales.ToList(); pseudoLocalesList.Sort(); m_ProjectPseudoLocales = pseudoLocalesList.AsReadOnly(); } } internal virtual void SetPreloadTableInternal(LocalizationTable table, bool preload, bool createUndo = false) { if (table == null) throw new ArgumentNullException(nameof(table), "Can not set preload flag on a null table"); var aaSettings = GetAddressableAssetSettings(true); if (aaSettings == null) return; var tableEntry = GetAssetEntry(table); if (tableEntry == null) throw new AddressableEntryNotFoundException(table); if (createUndo) Undo.RecordObjects(new Object[] { aaSettings, tableEntry.parentGroup }, "Set Preload flag"); tableEntry.SetLabel(LocalizationSettings.PreloadLabel, preload, preload); } internal virtual bool GetPreloadTableFlagInternal(LocalizationTable table) { if (table == null) throw new ArgumentNullException(nameof(table), "Can not get preload flag from a null table"); var aaSettings = GetAddressableAssetSettings(false); if (aaSettings == null) return false; var tableEntry = GetAssetEntry(table); if (tableEntry == null) throw new AddressableEntryNotFoundException(table); return tableEntry.labels.Contains(LocalizationSettings.PreloadLabel); } void UndoRedoPerformed() { // Reset the locales as adding/removing a locale may have been undone. m_ProjectLocales = null; } internal virtual void CreateAsset(Object asset, string path) { AssetDatabase.CreateAsset(asset, path); } internal string GetAssetGuid(int instanceId) { Debug.Assert(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(instanceId, out string guid, out long _), "Failed to extract the asset Guid"); return guid; } internal string GetAssetGuid(Object asset) { Debug.Assert(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out string guid, out long _), "Failed to extract the asset Guid", asset); return guid; } internal string IsTableNameValid(Type collectionType, string tableName) { if (string.IsNullOrWhiteSpace(tableName)) { return "Table collection name cannot be blank or whitespace"; } if (tableName != tableName.Trim()) { return "Table collection name cannot contain leading or trailing whitespace"; } // Addressables restriction if (tableName.Contains('[') && tableName.Contains(']')) { return "Table collection name cannot contain both '[' and ']'"; } var values = k_InvalidFileNameChars.Intersect(tableName).ToList(); if (values.Any()) { return $"Table collection name cannot contain invalid filename characters but contains '{string.Join(", ", values)}'"; } if (TableCollectionCache.FindTableCollection(collectionType, tableName) != null) { return $"{collectionType.Name} with name '{tableName}' already exists"; } return null; } } }