using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using UnityEditor.AddressableAssets.Settings; using UnityEditor.Localization.Addressables; using UnityEngine; using UnityEngine.Localization; using UnityEngine.Localization.Metadata; using UnityEngine.Localization.Tables; using UnityEngine.Pool; using Object = UnityEngine.Object; namespace UnityEditor.Localization { /// /// Contains information on all the tables associated with a table collection name and type. /// public class AssetTableCollection : LocalizationTableCollection { /// protected internal override Type TableType => typeof(AssetTable); /// protected internal override Type RequiredExtensionAttribute => typeof(AssetTableCollectionExtensionAttribute); const string k_AssetTypeSetByScript = "set-by-script"; /// /// A helper property which is the contents of loaded and cast to . /// public virtual ReadOnlyCollection AssetTables => new ReadOnlyCollection(Tables.Select(t => t.asset as AssetTable).ToList().AsReadOnly()); /// protected internal override string DefaultGroupName => "Asset Table"; /// /// Returns an enumerator that can be used to step through each key and its localized values, such as in a foreach loop. /// Internally and 's are separate assets with their own internal list of values. /// This means that when iterating through each key a lookup must be made in each table in order to retrieve the localized value, /// this can become slow when dealing with a large number of tables and entries. /// GetRowEnumerator improves this process by first sorting the multiple internal lists and then stepping through each conceptual row at a time. /// It handles missing keys and table entries and provides a more efficient and faster way to iterate through the tables. /// /// public IEnumerable> GetRowEnumerator() => GetRowEnumerator(AssetTables); /// protected override void RemoveTableFromAddressables(LocalizationTable table, bool createUndo) { if (table is AssetTable assetTable) { // This can be called when a table is about to be deleted or when it is removed from the collection. // If the asset is removed from the collection it will still exists but not be part of a build, essentially is is turned off. // A user may want to turn the asset back on at a later time and have all the assets restored, so when we remove the table we need // to keep its entries so that they can be added again. // We can do this by saving the state using JsonUtility and then restoring it afterwards. // Save the state var json = JsonUtility.ToJson(table); // We need to make a copy as the original will be modified as we remove each item. var valuesCopy = assetTable.Values.ToArray(); foreach (var entry in valuesCopy) { RemoveAssetFromTable(assetTable, entry.KeyId); } // Restore the entries JsonUtility.FromJsonOverwrite(json, table); } base.RemoveTableFromAddressables(table, createUndo); } /// /// Add a localized asset to the asset table that matches the . /// This function ensures the localization system adds the asset to the Addressables system and sets the asset up for use. /// /// The table to add the asset to, if a table with the id does not exist a new one will be created. /// The table entry Key or Key Id. /// The asset to add. /// Should an undo operation be created? public void AddAssetToTable(LocaleIdentifier localeIdentifier, TableEntryReference entryReference, Object asset, bool createUndo = false) { var table = GetTable(localeIdentifier) ?? AddNewTable(localeIdentifier); AddAssetToTable(table as AssetTable, entryReference, asset, createUndo); } /// /// Add a localized asset to the asset table. /// This function will ensure the localization system adds the asset to the Addressables system and sets the asset up for use. /// /// The table to add the asset to, must be part of the collection. /// The table entry Key or Key Id. /// The asset to add. /// Should an undo operation be created? public virtual void AddAssetToTable(AssetTable table, TableEntryReference entryReference, Object asset, bool createUndo = false) { if (table == null) throw new ArgumentNullException(nameof(table), "Can not add asset to null table"); if (asset == null) throw new ArgumentNullException(nameof(asset), "Can not add null asset to table"); if (!ContainsTable(table)) { throw new Exception("The table does not belong to this collection."); } if (!IsPersistentAsset(table)) throw new AssetNotPersistentException(table); if (!IsPersistentAsset(asset)) throw new AssetNotPersistentException(asset); // Reject Editor assets such as Folders. (LOC-1107) if (asset is DefaultAsset) throw new ArgumentException(nameof(asset), $"Can not add unsupported asset `{asset}` to table."); var path = AssetDatabase.GetAssetPath(asset); if (AssetUtility.IsBuiltInResource(path)) throw new ArgumentException($"Builtin resources are not supported. Can not add {asset} from {path}.", nameof(asset)); // Add the asset to the Addressables system and setup the table with the key to guid mapping. var aaSettings = LocalizationEditorSettings.Instance.GetAddressableAssetSettings(true); if (aaSettings == null) return; using (new UndoScope("Add asset to table", createUndo)) { if (createUndo) Undo.RecordObject(aaSettings, "Add asset to table"); // Remove the old asset first var assetGuid = LocalizationEditorSettings.Instance.GetAssetGuid(asset); var tableEntry = table.GetEntryFromReference(entryReference); if (tableEntry != null) { if (tableEntry.Guid != assetGuid) RemoveAssetFromTable(table, entryReference, createUndo); } // Has the asset already been added? Perhaps it is being used by multiple tables or the user has added it manually. var entry = aaSettings.FindAssetEntry(assetGuid); var entryLabel = AddressHelper.FormatAssetLabel(table.LocaleIdentifier.Code); aaSettings.AddLabel(entryLabel); if (entry == null) { entry = AddressableGroupRules.AddAssetToGroup(asset, new[] {table.LocaleIdentifier}, aaSettings, createUndo); entry.SetLabel(entryLabel, true, true); var mainAsset = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GetAssetPath(asset)); if (mainAsset == null) mainAsset = asset; entry.address = LocalizationEditorSettings.Instance.FindUniqueAssetAddress(mainAsset.name); } else { if (createUndo) Undo.RecordObject(entry.parentGroup, "Add asset to table"); entry.SetLabel(entryLabel, true, true); UpdateAssetGroup(aaSettings, entry, createUndo); } if (createUndo) Undo.RecordObjects(new Object[] { table, table.SharedData }, "Add asset to table"); EditorUtility.SetDirty(table); EditorUtility.SetDirty(table.SharedData); if (AssetDatabase.IsSubAsset(asset)) assetGuid = AssetAddress.FormatAddress(assetGuid, asset.name); tableEntry = table.AddEntryFromReference(entryReference, assetGuid); SetEntryAssetType(tableEntry.KeyId, asset.GetType(), table.LocaleIdentifier.Code); LocalizationEditorSettings.EditorEvents.RaiseAssetTableEntryAdded(this, table, tableEntry); } } internal virtual bool IsPersistentAsset(Object asset) => EditorUtility.IsPersistent(asset); /// /// Remove the asset mapping from the table entry and also cleans up the Addressables if necessary. /// /// /// /// public void RemoveAssetFromTable(AssetTable table, TableEntryReference entryReference, bool createUndo = false) { using (new UndoScope("Remove asset from table", createUndo)) { // Clear the asset but keep the key var tableEntry = table.GetEntryFromReference(entryReference); if (tableEntry == null) return; var removedAssetGuid = tableEntry.Guid; tableEntry.Address = string.Empty; var aaSettings = LocalizationEditorSettings.Instance.GetAddressableAssetSettings(false); if (aaSettings == null) return; EditorUtility.SetDirty(table); EditorUtility.SetDirty(table.SharedData); RemoveEntryAssetType(tableEntry.KeyId, table.LocaleIdentifier.Code); // If the entry has metadata then we will leave an empty entry otherwise we just remove the whole thing. if (tableEntry.MetadataEntries.Count == 0 && !string.IsNullOrEmpty(removedAssetGuid)) table.RemoveEntry(tableEntry.KeyId); // Determine if the asset is being referenced by any entries or tables with the same locale, if not then we can // remove the locale label and if no other labels exist also remove the asset from the Addressables system. var assetTableCollections = LocalizationEditorSettings.GetAssetTableCollections(); foreach (var collection in assetTableCollections) { if (collection.GetTable(table.LocaleIdentifier) is AssetTable tableWithMatchingLocaleId && tableWithMatchingLocaleId.ContainsValue(removedAssetGuid)) { // The asset is referenced elsewhere by a table with the same Locale so we can not remove the locale label or asset. return; } } // Remove the locale label for this asset var assetEntry = aaSettings.FindAssetEntry(removedAssetGuid); if (assetEntry != null) { if (createUndo) Undo.RecordObject(assetEntry.parentGroup, "Remove asset from table"); var assetLabel = AddressHelper.FormatAssetLabel(table.LocaleIdentifier); assetEntry.SetLabel(assetLabel, false); UpdateAssetGroup(aaSettings, assetEntry, createUndo); } LocalizationEditorSettings.EditorEvents.RaiseAssetTableEntryRemoved(this, table, tableEntry, removedAssetGuid); } } /// /// Returns the expected type for the entry. /// When an asset is first added to an entry, the type is recorded so that the Editor can ensure all subsequent assets that are added are compatible. /// /// The entry to return the asset type for. /// The expected asset type or typeof(Object) if unknown. public Type GetEntryAssetType(TableEntryReference tableEntry) { long keyId = tableEntry.ReferenceType == TableEntryReference.Type.Name ? SharedData.GetId(tableEntry.Key) : tableEntry.KeyId; foreach (AssetTypeMetadata assetType in SharedData.Metadata.MetadataEntries) { if (assetType == null) continue; if (assetType.Contains(keyId)) { return assetType.Type; } } return typeof(Object); } /// /// The type of asset that is expected by this entry. By default this is determined by the first asset that is added in the Editor /// however this can be used to override it so it always expects this asset type instead of reverting back to Object when the last asset is removed. /// /// The entry to set the asset type for. /// The asset type to expect for this entry. To reset the override and allow the Editor to control the type pass or typeof(Object). public void SetEntryAssetType(TableEntryReference tableEntry, Type assetType) { if (assetType == null || assetType == typeof(Object)) RemoveEntryAssetType(tableEntry, k_AssetTypeSetByScript); else SetEntryAssetType(tableEntry, assetType, k_AssetTypeSetByScript); } void SetEntryAssetType(TableEntryReference tableEntry, Type assetType, string entryCode) { long keyId = tableEntry.ReferenceType == TableEntryReference.Type.Name ? SharedData.GetId(tableEntry.Key, true) : tableEntry.KeyId; // Update type metadata AssetTypeMetadata entryMetadata = null; AssetTypeMetadata typeMetadata = null; // We cant use a foreach here as we are sometimes inside of a loop and exceptions will be thrown (Collection was modified). for (int i = 0; i < SharedData.Metadata.MetadataEntries.Count; ++i) { var md = SharedData.Metadata.MetadataEntries[i]; if (md is AssetTypeMetadata at) { if (at.Contains(keyId)) { if (!at.Type.IsAssignableFrom(assetType)) { at.RemoveEntry(keyId, entryCode); if (at.IsEmpty) { SharedData.Metadata.RemoveMetadata(at); } // Are other tables still using the type for the same id? if (at.Contains(tableEntry.KeyId)) { var name = SharedData.GetEntry(tableEntry.KeyId); Debug.LogWarning($"Table entry {name}({tableEntry.KeyId}) contains mixed types. Both {at.Type} and {assetType} are used."); } } else { entryMetadata = at; break; } } if (at.Type == assetType) { typeMetadata = at; break; } } } var foundMetadata = entryMetadata ?? typeMetadata; if (foundMetadata == null) { foundMetadata = new AssetTypeMetadata() { Type = assetType }; SharedData.Metadata.AddMetadata(foundMetadata); } foundMetadata.AddEntry(keyId, entryCode); } void RemoveEntryAssetType(TableEntryReference tableEntry, string entryCode) { long keyId = tableEntry.ReferenceType == TableEntryReference.Type.Name ? SharedData.GetId(tableEntry.Key) : tableEntry.KeyId; if (keyId == SharedTableData.EmptyId) return; // Update type metadata // We cant use a foreach here as we are sometimes inside of a loop and exceptions will be thrown (Collection was modified). for (int i = 0; i < SharedData.Metadata.MetadataEntries.Count; ++i) { var md = SharedData.Metadata.MetadataEntries[i]; if (md is AssetTypeMetadata at) { if (at.Contains(keyId)) { at.RemoveEntry(keyId, entryCode); if (at.IsEmpty) { SharedData.Metadata.RemoveMetadata(at); } return; } } } } /// /// Updates the group the asset should belong to. /// Assets will be stored in groups for the Locale they are used by unless they are used /// by more than 1 , then they will be moved to the shared group. /// /// /// /// Used to indicate if an Undo operation should be created. protected virtual void UpdateAssetGroup(AddressableAssetSettings settings, AddressableAssetEntry assetEntry, bool createUndo) { if (settings == null || assetEntry == null) return; // Find all the locales that are using the asset using the Addressable labels. var localesUsingAsset = ListPool.Get(); foreach (var label in assetEntry.labels) { if (AddressHelper.TryGetLocaleLabelToId(label, out var id)) { localesUsingAsset.Add(id); } } // If no Locales depend on this asset then we can just remove it if (localesUsingAsset.Count == 0) { var oldGroup = assetEntry.parentGroup; settings.RemoveAssetEntry(assetEntry.guid); if (oldGroup.entries.Count == 0) { if (createUndo) { // We cant use undo asset deletion so we will leave an empty group instead of deleting it. Undo.RecordObject(oldGroup, "Remove group"); } else { settings.RemoveGroup(oldGroup); } } ListPool.Release(localesUsingAsset); return; } AddressableGroupRules.AddAssetToGroup(assetEntry.MainAsset, localesUsingAsset, settings, createUndo); } /// public override void RemoveEntry(TableEntryReference entryReference) { var entry = SharedData.GetEntryFromReference(entryReference); if (entry == null) return; foreach (var table in AssetTables) table.RemoveEntry(entry.Id); SharedData.RemoveKey(entry.Key); LocalizationEditorSettings.EditorEvents.RaiseTableEntryRemoved(this, entry); } /// public override void ClearAllEntries() { foreach (var table in AssetTables) { if (table == null) continue; for (int i = 0; i < table.Values.Count; ++i) { var tableEntry = table.Values.ElementAt(i); // Remove the asset from Addressables if (!tableEntry.IsEmpty) { RemoveAssetFromTable(table, tableEntry.KeyId); } } table.Clear(); EditorUtility.SetDirty(table); } base.ClearAllEntries(); } } }