Files

792 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Text;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEditor.SceneManagement;
public static class UnityLocalizationProjectSync
{
private const string SessionKey = "Bansonic.Localization.Sync.Completed";
private const string RootDirectory = "Assets/Localization";
private const string SettingsDirectory = RootDirectory + "/Settings";
private const string LocalesDirectory = RootDirectory + "/Locales";
private const string TablesDirectory = RootDirectory + "/Tables";
private const string SettingsAssetPath = SettingsDirectory + "/ProjectLocalizationSettings.asset";
private const string ZhJsonPath = "Assets/StreamingAssets/localization/zh-CN.json";
private const string EnJsonPath = "Assets/StreamingAssets/localization/en-US.json";
private static readonly string[] LiteralAssetExtensions = { ".prefab", ".unity" };
private static readonly string[] SkippedPathPrefixes =
{
"Assets/Airy UI/_Demo Scenes/",
"Assets/Cartoon UI/",
"Assets/Spine Examples/",
"Assets/TextMesh Pro/Examples & Extras/",
"Assets/Localization/"
};
private static readonly Regex LocalizedTextRegex =
new Regex(@"LocalizationService\.(Get|GetFormat)\(\s*""(?<key>[^""]+)""\s*,\s*""(?<fallback>(?:\\.|[^""])*)""", RegexOptions.Compiled);
private static readonly Regex LiteralRegex =
new Regex(@"LocalizationService\.LocalizeLiteral\(\s*""(?<literal>(?:\\.|[^""])*)""\s*\)", RegexOptions.Compiled);
private static readonly Regex NoticeLiteralRegex =
new Regex(@"gNotice\.\w+\.display\(\s*""(?<literal>(?:\\.|[^""])*)""", RegexOptions.Compiled);
private static readonly Regex YamlTextRegex =
new Regex(@"^\s*m_(Text|text):\s*(?<value>.*)$", RegexOptions.Compiled);
private static readonly HashSet<string> ScriptableObjectStringExclusionPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"songName"
};
[InitializeOnLoadMethod]
private static void ScheduleAutoSync()
{
EditorApplication.delayCall += AutoSyncIfNeeded;
}
[MenuItem("Tools/Localization/Rebuild Official Localization Assets")]
public static void RebuildOfficialLocalizationAssets()
{
try
{
EnsureLocalizationAssets(forceRebuild: true);
Debug.Log("[Localization] Official localization assets rebuilt successfully.");
}
catch (Exception ex)
{
Debug.LogError($"[Localization] Rebuild failed: {ex}");
throw;
}
}
[MenuItem("Tools/Localization/Import JSON To Official Tables")]
public static void ImportJsonToOfficialTables()
{
try
{
EnsureLocalizationAssets(forceRebuild: true);
Debug.Log("[Localization] Imported JSON packs into official localization tables.");
}
catch (Exception ex)
{
Debug.LogError($"[Localization] Import JSON failed: {ex}");
throw;
}
}
[MenuItem("Tools/Localization/Export Official Tables To JSON")]
public static void ExportOfficialTablesToJson()
{
try
{
EnsureDirectories();
LocalizationSettings settings = EnsureSettingsAsset();
LocalizationEditorSettings.ActiveLocalizationSettings = settings;
Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset");
Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset");
StringTableCollection gameTextCollection = LocalizationEditorSettings.GetStringTableCollection(LocalizationService.GameTextTableCollectionName);
StringTableCollection literalCollection = LocalizationEditorSettings.GetStringTableCollection(LocalizationService.LiteralTextTableCollectionName);
Dictionary<string, string> zhEntries = ExtractEntries(gameTextCollection, zhLocale);
Dictionary<string, string> enEntries = ExtractEntries(gameTextCollection, enLocale);
Dictionary<string, string> zhLiterals = ExtractEntries(literalCollection, zhLocale);
Dictionary<string, string> enLiterals = ExtractEntries(literalCollection, enLocale);
SavePack(ZhJsonPath, "zh-CN", zhEntries, zhLiterals);
SavePack(EnJsonPath, "en-US", enEntries, enLiterals);
AssetDatabase.Refresh();
Debug.Log("[Localization] Exported official localization tables to JSON.");
}
catch (Exception ex)
{
Debug.LogError($"[Localization] Export JSON failed: {ex}");
throw;
}
}
private static void AutoSyncIfNeeded()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
if (SessionState.GetBool(SessionKey, false))
{
return;
}
SessionState.SetBool(SessionKey, true);
try
{
if (EditorApplication.isCompiling || EditorApplication.isUpdating)
{
return;
}
if (HasCoreLocalizationAssets())
{
return;
}
EnsureLocalizationShellAssets();
}
catch (Exception ex)
{
Debug.LogError($"[Localization] Auto sync failed: {ex}");
}
}
private static void EnsureLocalizationAssets(bool forceRebuild)
{
EnsureDirectories();
LocalizationSettings settings = EnsureSettingsAsset();
LocalizationEditorSettings.ActiveLocalizationSettings = settings;
Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset");
Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset");
var locales = new List<Locale> { zhLocale, enLocale };
StringTableCollection gameTextCollection = EnsureStringCollection(LocalizationService.GameTextTableCollectionName, locales);
StringTableCollection literalCollection = EnsureStringCollection(LocalizationService.LiteralTextTableCollectionName, locales);
LocalizationLanguagePack zhPack = LoadPack(ZhJsonPath);
LocalizationLanguagePack enPack = LoadPack(EnJsonPath);
Dictionary<string, string> zhEntries = BuildEntryMap(zhPack?.entries, scanCodeFallbacks: true, preferJsonValues: true);
Dictionary<string, string> enEntries = BuildEntryMap(enPack?.entries, scanCodeFallbacks: false, preferJsonValues: true);
BackfillMissingEntries(zhEntries, enEntries);
Dictionary<string, string> zhLiterals = BuildLiteralMap(zhPack?.literals, enPack?.literals, english: false);
Dictionary<string, string> enLiterals = BuildLiteralMap(zhPack?.literals, enPack?.literals, english: true);
SavePack(ZhJsonPath, "zh-CN", zhEntries, zhLiterals);
SavePack(EnJsonPath, "en-US", enEntries, enLiterals);
ApplyEntries(gameTextCollection, zhLocale, zhEntries);
ApplyEntries(gameTextCollection, enLocale, enEntries);
ApplyEntries(literalCollection, zhLocale, zhLiterals);
ApplyEntries(literalCollection, enLocale, enLiterals);
SetPreloadFlags(gameTextCollection);
SetPreloadFlags(literalCollection);
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
private static bool HasCoreLocalizationAssets()
{
if (!File.Exists(SettingsAssetPath))
{
return false;
}
LocalizationSettings settings = AssetDatabase.LoadAssetAtPath<LocalizationSettings>(SettingsAssetPath);
if (settings == null)
{
return false;
}
if (LocalizationEditorSettings.GetLocale("zh-CN") == null)
{
return false;
}
if (LocalizationEditorSettings.GetLocale("en-US") == null)
{
return false;
}
if (LocalizationEditorSettings.GetStringTableCollection(LocalizationService.GameTextTableCollectionName) == null)
{
return false;
}
if (LocalizationEditorSettings.GetStringTableCollection(LocalizationService.LiteralTextTableCollectionName) == null)
{
return false;
}
return true;
}
private static void EnsureLocalizationShellAssets()
{
EnsureDirectories();
LocalizationSettings settings = EnsureSettingsAsset();
LocalizationEditorSettings.ActiveLocalizationSettings = settings;
Locale zhLocale = EnsureLocale("zh-CN", "Chinese (Simplified)", $"{LocalesDirectory}/zh-CN.asset");
Locale enLocale = EnsureLocale("en-US", "English (United States)", $"{LocalesDirectory}/en-US.asset");
var locales = new List<Locale> { zhLocale, enLocale };
EnsureStringCollection(LocalizationService.GameTextTableCollectionName, locales);
EnsureStringCollection(LocalizationService.LiteralTextTableCollectionName, locales);
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
}
private static void EnsureDirectories()
{
Directory.CreateDirectory(SettingsDirectory);
Directory.CreateDirectory(LocalesDirectory);
Directory.CreateDirectory(TablesDirectory);
}
private static LocalizationSettings EnsureSettingsAsset()
{
var settings = AssetDatabase.LoadAssetAtPath<LocalizationSettings>(SettingsAssetPath);
if (settings != null)
{
return settings;
}
settings = ScriptableObject.CreateInstance<LocalizationSettings>();
AssetDatabase.CreateAsset(settings, SettingsAssetPath);
return settings;
}
private static Locale EnsureLocale(string code, string localeName, string assetPath)
{
Locale locale = LocalizationEditorSettings.GetLocale(code);
if (locale == null)
{
locale = AssetDatabase.LoadAssetAtPath<Locale>(assetPath);
}
if (locale == null)
{
locale = Locale.CreateLocale(new LocaleIdentifier(code));
locale.name = localeName;
locale.LocaleName = localeName;
AssetDatabase.CreateAsset(locale, assetPath);
}
if (LocalizationEditorSettings.GetLocale(code) == null)
{
LocalizationEditorSettings.AddLocale(locale);
}
EditorUtility.SetDirty(locale);
return locale;
}
private static StringTableCollection EnsureStringCollection(string tableName, IList<Locale> locales)
{
StringTableCollection collection = LocalizationEditorSettings.GetStringTableCollection(tableName);
if (collection == null)
{
collection = LocalizationEditorSettings.CreateStringTableCollection(tableName, TablesDirectory, locales);
}
for (int i = 0; i < locales.Count; i++)
{
if (collection.GetTable(locales[i].Identifier) == null)
{
collection.AddNewTable(locales[i].Identifier);
}
}
EditorUtility.SetDirty(collection);
return collection;
}
private static LocalizationLanguagePack LoadPack(string assetPath)
{
if (!File.Exists(assetPath))
{
return new LocalizationLanguagePack();
}
string json = File.ReadAllText(assetPath);
LocalizationLanguagePack pack = JsonConvert.DeserializeObject<LocalizationLanguagePack>(json);
return pack ?? new LocalizationLanguagePack();
}
private static Dictionary<string, string> BuildEntryMap(Dictionary<string, string> seedEntries, bool scanCodeFallbacks, bool preferJsonValues)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (seedEntries != null)
{
foreach (var pair in seedEntries)
{
if (!string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
{
result[pair.Key] = pair.Value;
}
}
}
if (!scanCodeFallbacks)
{
return result;
}
foreach (string sourceFile in EnumerateSourceFiles("Assets"))
{
string content = File.ReadAllText(sourceFile);
foreach (Match match in LocalizedTextRegex.Matches(content))
{
string key = Regex.Unescape(match.Groups["key"].Value);
string fallback = Regex.Unescape(match.Groups["fallback"].Value);
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(fallback))
{
continue;
}
if (!result.ContainsKey(key) || !preferJsonValues)
{
result[key] = fallback;
}
}
}
return result;
}
private static Dictionary<string, string> BuildLiteralMap(
Dictionary<string, string> zhSeed,
Dictionary<string, string> enSeed,
bool english)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
Dictionary<string, string> primary = english ? enSeed : zhSeed;
if (primary != null)
{
foreach (var pair in primary)
{
if (!string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
{
result[pair.Key] = pair.Value;
}
}
}
foreach (string sourceFile in EnumerateSourceFiles("Assets"))
{
string content = File.ReadAllText(sourceFile);
MergeLiteralMatches(content, LiteralRegex, result, english);
MergeLiteralMatches(content, NoticeLiteralRegex, result, english);
}
foreach (string assetPath in EnumerateLiteralAssetPaths())
{
foreach (string literal in ReadLiteralStringsFromYaml(assetPath))
{
if (!result.ContainsKey(literal))
{
result[literal] = literal;
}
}
}
foreach (string literal in EnumerateScriptableObjectLiterals())
{
if (!result.ContainsKey(literal))
{
result[literal] = literal;
}
}
return result;
}
private static void MergeLiteralMatches(string content, Regex regex, Dictionary<string, string> target, bool english)
{
foreach (Match match in regex.Matches(content))
{
string literal = Regex.Unescape(match.Groups["literal"].Value);
if (!ShouldCaptureLiteral(literal))
{
continue;
}
if (!target.ContainsKey(literal))
{
target[literal] = literal;
}
}
}
private static IEnumerable<string> EnumerateSourceFiles(string rootDirectory)
{
return Directory.EnumerateFiles(rootDirectory, "*.cs", SearchOption.AllDirectories)
.Where(path => path.IndexOf("\\Editor\\", StringComparison.OrdinalIgnoreCase) < 0);
}
private static IEnumerable<string> EnumerateLiteralAssetPaths()
{
return Directory.EnumerateFiles("Assets", "*.*", SearchOption.AllDirectories)
.Where(path => LiteralAssetExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase))
.Where(path => !ShouldSkipAssetPath(path));
}
private static IEnumerable<string> ReadLiteralStringsFromYaml(string assetPath)
{
foreach (string rawLine in File.ReadLines(assetPath))
{
Match match = YamlTextRegex.Match(rawLine);
if (!match.Success)
{
continue;
}
string value;
try
{
value = DecodeYamlText(match.Groups["value"].Value);
}
catch (Exception ex)
{
Debug.LogWarning($"[Localization] Skipped malformed YAML text in {assetPath}: {ex.Message}");
continue;
}
if (ShouldCaptureLiteral(value))
{
yield return value;
}
}
}
private static IEnumerable<string> EnumerateScriptableObjectLiterals()
{
string[] guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { "Assets" });
for (int i = 0; i < guids.Length; i++)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
if (string.IsNullOrWhiteSpace(assetPath) || ShouldSkipAssetPath(assetPath))
{
continue;
}
ScriptableObject asset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(assetPath);
if (asset == null)
{
continue;
}
SerializedObject serializedObject = new SerializedObject(asset);
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = true;
if (iterator.propertyType != SerializedPropertyType.String)
{
continue;
}
if (ShouldSkipScriptableObjectString(asset, iterator))
{
continue;
}
string literal = iterator.stringValue;
if (ShouldCaptureLiteral(literal))
{
yield return literal.Trim();
}
}
}
}
private static bool ShouldSkipScriptableObjectString(ScriptableObject asset, SerializedProperty property)
{
if (asset == null || property == null)
{
return true;
}
if (string.Equals(property.propertyPath, "m_Script", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (asset is SongData && ScriptableObjectStringExclusionPaths.Contains(property.propertyPath))
{
return true;
}
return false;
}
private static string DecodeYamlText(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
string value = rawValue.Trim();
if (value == "\"\"" || value == "''")
{
return string.Empty;
}
bool isDoubleQuoted = value.Length >= 2 &&
value.StartsWith("\"", StringComparison.Ordinal) &&
value.EndsWith("\"", StringComparison.Ordinal);
bool isSingleQuoted = value.Length >= 2 &&
value.StartsWith("'", StringComparison.Ordinal) &&
value.EndsWith("'", StringComparison.Ordinal);
if (isDoubleQuoted || isSingleQuoted)
{
if (isDoubleQuoted)
{
try
{
value = JsonConvert.DeserializeObject<string>(value);
}
catch
{
value = value.Substring(1, value.Length - 2);
}
}
else
{
value = value.Substring(1, value.Length - 2);
}
}
else
{
value = DecodeYamlEscapes(value);
}
return value.Trim();
}
private static string DecodeYamlEscapes(string value)
{
if (string.IsNullOrEmpty(value) || value.IndexOf('\\') < 0)
{
return value;
}
var builder = new StringBuilder(value.Length);
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
if (current != '\\' || i + 1 >= value.Length)
{
builder.Append(current);
continue;
}
char next = value[i + 1];
switch (next)
{
case 'n':
builder.Append('\n');
i++;
break;
case 'r':
builder.Append('\r');
i++;
break;
case 't':
builder.Append('\t');
i++;
break;
case '"':
case '\'':
case '\\':
builder.Append(next);
i++;
break;
case 'u' when i + 5 < value.Length:
string hex = value.Substring(i + 2, 4);
if (ushort.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out ushort code))
{
builder.Append((char)code);
i += 5;
}
else
{
builder.Append(current);
}
break;
case 'x':
builder.Append(next);
i++;
break;
default:
builder.Append(current);
break;
}
}
return builder.ToString();
}
private static bool ShouldCaptureLiteral(string literal)
{
if (string.IsNullOrWhiteSpace(literal))
{
return false;
}
string trimmed = literal.Trim();
if (trimmed.Length == 0 || trimmed.Length > 512)
{
return false;
}
if (trimmed.StartsWith("guid:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
bool hasLetterLikeContent = trimmed.Any(ch => char.IsLetter(ch) || IsCjk(ch));
return hasLetterLikeContent;
}
private static bool IsCjk(char ch)
{
return ch >= 0x4E00 && ch <= 0x9FFF;
}
private static bool ShouldSkipAssetPath(string path)
{
string normalized = path.Replace('\\', '/');
for (int i = 0; i < SkippedPathPrefixes.Length; i++)
{
if (normalized.StartsWith(SkippedPathPrefixes[i], StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static void BackfillMissingEntries(Dictionary<string, string> zhEntries, Dictionary<string, string> enEntries)
{
foreach (string key in zhEntries.Keys.ToArray())
{
if (!enEntries.ContainsKey(key))
{
enEntries[key] = zhEntries[key];
}
}
foreach (string key in enEntries.Keys.ToArray())
{
if (!zhEntries.ContainsKey(key))
{
zhEntries[key] = enEntries[key];
}
}
}
private static void ApplyEntries(StringTableCollection collection, Locale locale, Dictionary<string, string> entries)
{
if (collection == null || locale == null)
{
return;
}
StringTable table = collection.GetTable(locale.Identifier) as StringTable;
if (table == null)
{
table = collection.AddNewTable(locale.Identifier) as StringTable;
}
foreach (var pair in entries)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
StringTableEntry entry = table.GetEntry(pair.Key);
if (entry == null)
{
table.AddEntry(pair.Key, pair.Value ?? string.Empty);
}
else if (entry.LocalizedValue != (pair.Value ?? string.Empty))
{
entry.Value = pair.Value ?? string.Empty;
}
}
EditorUtility.SetDirty(table);
EditorUtility.SetDirty(table.SharedData);
}
private static Dictionary<string, string> ExtractEntries(StringTableCollection collection, Locale locale)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (collection == null || locale == null)
{
return result;
}
StringTable table = collection.GetTable(locale.Identifier) as StringTable;
if (table == null)
{
return result;
}
foreach (StringTableEntry entry in table.Values)
{
if (entry == null || string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}
result[entry.Key] = entry.LocalizedValue ?? string.Empty;
}
return result;
}
private static void SavePack(string assetPath, string languageCode, Dictionary<string, string> entries, Dictionary<string, string> literals)
{
var pack = new LocalizationLanguagePack
{
languageCode = languageCode,
entries = new Dictionary<string, string>(entries, StringComparer.Ordinal),
literals = new Dictionary<string, string>(literals, StringComparer.Ordinal)
};
string json = JsonConvert.SerializeObject(pack, Formatting.Indented);
File.WriteAllText(assetPath, json);
}
private static void SetPreloadFlags(StringTableCollection collection)
{
foreach (var table in collection.StringTables)
{
LocalizationEditorSettings.SetPreloadTableFlag(table, true);
EditorUtility.SetDirty(table);
}
}
}