206 lines
8.7 KiB
C#
206 lines
8.7 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using BaseGames.Localization;
|
||
|
||
namespace BaseGames.Editor.Localization
|
||
{
|
||
/// <summary>
|
||
/// 本地化 CSV(Excel 往返)读写共享工具。
|
||
///
|
||
/// CSV 约定:
|
||
/// - 列顺序:<c>key, ChineseSimplified, English, Japanese, Korean</c>(表头用 <see cref="Language"/> 枚举名)。
|
||
/// - 以 <b>UTF-8 BOM</b> 写出,使 Excel 双击打开时中日韩文不乱码。
|
||
/// - 解析遵循 RFC 4180(带引号字段可含逗号 / 换行 / 双引号转义),并自动剥离前导 BOM。
|
||
/// - 导入语义:合并(覆盖已有 key、追加新 key、不删除多余 key)。
|
||
/// </summary>
|
||
public static class LocalizationCsv
|
||
{
|
||
/// <summary>导出列顺序(与导入表头解析共用)。</summary>
|
||
public static readonly Language[] Columns = LocalizationFileIO.AllLanguages;
|
||
|
||
// 带 BOM 的 UTF-8(Excel 友好)
|
||
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
|
||
|
||
// ── 高层:表 ⇄ CSV 文件 ─────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 将给定的"语言→字典"导出为该表的 CSV 文件(含 BOM)。返回写出的资产路径。
|
||
/// </summary>
|
||
public static string ExportToFile(string table,
|
||
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
|
||
{
|
||
// 汇总所有语言出现过的 key(排序,保证稳定 diff)
|
||
var allKeys = new SortedSet<string>(StringComparer.Ordinal);
|
||
foreach (var lang in Columns)
|
||
if (langDicts.TryGetValue(lang, out var d) && d != null)
|
||
foreach (var k in d.Keys) allKeys.Add(k);
|
||
|
||
string csvText = BuildCsvText(allKeys, langDicts);
|
||
|
||
EditorScaffoldUtils.EnsureFolder(LocalizationPaths.ExportRoot);
|
||
string assetPath = LocalizationPaths.CsvPath(table);
|
||
File.WriteAllText(ToFullPath(assetPath), csvText, Utf8Bom);
|
||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||
return assetPath;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取该表的 CSV 文件并解析为"语言→字典"(仅含表头出现的语言列)。
|
||
/// CSV 文件不存在时返回 null。
|
||
/// </summary>
|
||
public static Dictionary<Language, Dictionary<string, string>> ParseFile(string table)
|
||
{
|
||
string assetPath = LocalizationPaths.CsvPath(table);
|
||
string fullPath = ToFullPath(assetPath);
|
||
if (!File.Exists(fullPath)) return null;
|
||
|
||
return ParseText(File.ReadAllText(fullPath, Encoding.UTF8));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将该表的 CSV 合并写回磁盘 JSON(供批处理工具用):
|
||
/// 对 CSV 表头出现的每个语言,读现有 JSON → 合并 CSV → <see cref="LocalizationFileIO.Write"/>。
|
||
/// 返回受影响的语言数;CSV 不存在时返回 0。
|
||
/// </summary>
|
||
public static int ImportFileToJson(string table)
|
||
{
|
||
var parsed = ParseFile(table);
|
||
if (parsed == null) return 0;
|
||
|
||
foreach (var (lang, csvDict) in parsed)
|
||
{
|
||
var merged = LocalizationFileIO.Read(lang, table); // 现有 JSON 副本
|
||
foreach (var kv in csvDict) merged[kv.Key] = kv.Value;
|
||
LocalizationFileIO.Write(lang, table, merged);
|
||
}
|
||
return parsed.Count;
|
||
}
|
||
|
||
// ── 低层:文本 ⇄ 行 ─────────────────────────────────────────────────────
|
||
|
||
/// <summary>构造 CSV 文本(表头 + 数据行,按 allKeys 顺序)。</summary>
|
||
public static string BuildCsvText(IEnumerable<string> orderedKeys,
|
||
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
|
||
{
|
||
var sb = new StringBuilder();
|
||
|
||
sb.Append("key");
|
||
foreach (var lang in Columns) sb.Append(',').Append(lang);
|
||
sb.Append('\n');
|
||
|
||
foreach (var key in orderedKeys)
|
||
{
|
||
sb.Append(Escape(key));
|
||
foreach (var lang in Columns)
|
||
{
|
||
string val = "";
|
||
if (langDicts.TryGetValue(lang, out var d) && d != null)
|
||
d.TryGetValue(key, out val);
|
||
sb.Append(',').Append(Escape(val ?? ""));
|
||
}
|
||
sb.Append('\n');
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>解析 CSV 文本为"语言→字典"(仅表头识别出的 Language 列)。</summary>
|
||
public static Dictionary<Language, Dictionary<string, string>> ParseText(string text)
|
||
{
|
||
var rows = ParseRows(text);
|
||
var result = new Dictionary<Language, Dictionary<string, string>>();
|
||
if (rows.Count < 2) return result;
|
||
|
||
// 表头:列号 → Language
|
||
var header = rows[0];
|
||
var langCols = new List<(int col, Language lang)>();
|
||
for (int col = 1; col < header.Count; col++)
|
||
if (Enum.TryParse<Language>(header[col].Trim(), out var lang))
|
||
{
|
||
langCols.Add((col, lang));
|
||
result[lang] = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
}
|
||
|
||
for (int row = 1; row < rows.Count; row++)
|
||
{
|
||
var cells = rows[row];
|
||
if (cells.Count == 0 || string.IsNullOrWhiteSpace(cells[0])) continue;
|
||
string key = cells[0];
|
||
foreach (var (col, lang) in langCols)
|
||
result[lang][key] = col < cells.Count ? cells[col] : "";
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>按 RFC 4180 规范转义单元格(含逗号 / 引号 / 换行时用双引号包裹)。</summary>
|
||
public static string Escape(string value)
|
||
{
|
||
if (value == null) return "";
|
||
bool needsQuote = value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0
|
||
|| value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0;
|
||
if (!needsQuote) return value;
|
||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||
}
|
||
|
||
/// <summary>RFC 4180 CSV 解析器,支持带引号字段(含换行),并剥离前导 UTF-8 BOM。</summary>
|
||
public static List<List<string>> ParseRows(string text)
|
||
{
|
||
var rows = new List<List<string>>();
|
||
if (string.IsNullOrEmpty(text)) return rows;
|
||
|
||
// 剥离前导 BOM(File.ReadAllText 以 UTF8 读时若文件带 BOM 会保留为 U+FEFF)
|
||
if (text[0] == '') text = text.Substring(1);
|
||
|
||
var row = new List<string>();
|
||
var cell = new StringBuilder();
|
||
bool inQuote = false;
|
||
int i = 0;
|
||
|
||
while (i < text.Length)
|
||
{
|
||
char c = text[i];
|
||
if (inQuote)
|
||
{
|
||
if (c == '"')
|
||
{
|
||
if (i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i += 2; }
|
||
else { inQuote = false; i++; }
|
||
}
|
||
else { cell.Append(c); i++; }
|
||
}
|
||
else
|
||
{
|
||
if (c == '"') { inQuote = true; i++; }
|
||
else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); i++; }
|
||
else if (c == '\r')
|
||
{
|
||
row.Add(cell.ToString()); cell.Clear();
|
||
rows.Add(row); row = new List<string>();
|
||
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||
i++;
|
||
}
|
||
else if (c == '\n')
|
||
{ row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List<string>(); i++; }
|
||
else { cell.Append(c); i++; }
|
||
}
|
||
}
|
||
|
||
if (cell.Length > 0 || row.Count > 0)
|
||
{
|
||
row.Add(cell.ToString());
|
||
rows.Add(row);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
private static string ToFullPath(string assetPath)
|
||
{
|
||
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
|
||
return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
|
||
}
|
||
}
|
||
}
|