Repository navigation
Expand file tree
/
Copy pathLocalizationLoader.cs
More file actions
198 lines (176 loc) · 8.33 KB
/
Copy pathLocalizationLoader.cs
File metadata and controls
198 lines (176 loc) · 8.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
using Jotunn.Entities;
using BepInEx;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Logging;
namespace LocalizationLoader;
/// <summary>
/// Loads all localizations embedded within the Localizations directory, and mirrors each one to
/// BepInEx/config/ImpactfulSkills/Localizations so players can edit or add translations without rebuilding.
///
/// Localizations should be plain JSON objects with each of the two required entries being separate eg:
/// "item_sword": "sword-name-here",
/// "item_sword_description": "sword-description-here",
/// the localization file itself should be a case matched language as defined by one of the "folder" language
/// names from here: https://valheim-modding.github.io/Jotunn/data/localization/language-list.html
/// </summary>
internal static class LocalizationLoader
{
internal const string LocalizationFolder = "Localizations";
// Only whole comment lines are stripped, so a translation containing "//" (eg a url) survives intact.
private static readonly Regex CommentLine = new Regex(@"^\s*//.*$", RegexOptions.Multiline);
internal static void AddLocalizations(BaseUnityPlugin plugin, string pluginID, CustomLocalization localization)
{
// Ensure localization folder exists
string translationFolder = Path.Combine(Directory.GetParent(plugin.Config.ConfigFilePath).FullName, pluginID, LocalizationFolder);
Directory.CreateDirectory(translationFolder);
Log.LogInfo("Loading Localizations.");
foreach (string embeddedResource in plugin.GetType().Assembly.GetManifestResourceNames())
{
if (!embeddedResource.Contains(LocalizationFolder)) { continue; }
string language = LanguageNameFromResource(embeddedResource);
if (language == null)
{
Log.LogWarning($"Could not determine a language name for '{embeddedResource}', skipping it.");
continue;
}
// A single broken user file should never take down the rest of the languages.
try
{
AddLocalization(plugin, localization, embeddedResource, language, Path.Combine(translationFolder, $"{language}.json"));
}
catch (Exception ex)
{
Log.LogError($"Failed to load localization '{language}': {ex.Message}");
}
}
}
private static void AddLocalization(BaseUnityPlugin plugin, CustomLocalization Localization, string embeddedResource, string language, string translationFile)
{
// since I use comments in the localization that are not valid JSON those need to be stripped
string cleaned_localization = CommentLine.Replace(ReadEmbeddedResourceFile(plugin, embeddedResource), "");
Dictionary<string, string> internal_localization = SimpleJson.SimpleJson.DeserializeObject<Dictionary<string, string>>(cleaned_localization);
// The embedded file is the source of truth for which tokens exist, and for the order they are written in.
List<string> token_order = internal_localization.Keys.ToList();
Dictionary<string, string> localization = internal_localization;
bool needs_write = true;
if (File.Exists(translationFile))
{
try
{
localization = SimpleJson.SimpleJson.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(translationFile));
needs_write = UpdateLocalizationWithMissingKeys(internal_localization, localization);
}
catch
{
// The players file is not valid JSON, fall back to the embedded copy and overwrite it.
Log.LogWarning($"'{translationFile}' could not be parsed, resetting it to the built in {language} localization.");
localization = internal_localization;
needs_write = true;
}
}
string translations = SerializePretty(localization, token_order);
// Only touch the file when something actually changed, so a players formatting and key order survive a restart.
if (needs_write)
{
File.WriteAllText(translationFile, translations, new UTF8Encoding(false));
Log.LogDebug($"Wrote {translationFile}");
}
Localization.AddJsonFile(language, translations);
Log.LogDebug($"Added localization: '{language}'");
}
/// <summary>
/// Brings the players localization in line with the embedded one - adds tokens the mod gained, removes
/// tokens the mod dropped. Returns true when anything changed.
/// </summary>
private static bool UpdateLocalizationWithMissingKeys(Dictionary<string, string> internal_localization, Dictionary<string, string> cached_localization)
{
bool changed = false;
List<string> extra_keys = cached_localization.Keys.Where(key => !internal_localization.ContainsKey(key)).ToList();
if (extra_keys.Count > 0)
{
Log.LogDebug($"Removing extra keys {string.Join(",", extra_keys)}.");
foreach (string key in extra_keys) { cached_localization.Remove(key); }
changed = true;
}
foreach (KeyValuePair<string, string> entry in internal_localization)
{
if (!cached_localization.ContainsKey(entry.Key))
{
Log.LogDebug($"Adding missing localization key {entry.Key}");
cached_localization.Add(entry.Key, entry.Value);
changed = true;
}
}
return changed;
}
/// <summary>
/// SimpleJson serializes to a single line, which is miserable to hand edit. These files exist to be edited,
/// so write them out one token per line in the embedded files order.
/// </summary>
private static string SerializePretty(Dictionary<string, string> localization, List<string> token_order)
{
StringBuilder sb = new StringBuilder();
sb.Append("{\n");
for (int i = 0; i < token_order.Count; i++)
{
string token = token_order[i];
sb.Append(" \"").Append(EscapeJsonString(token)).Append("\": \"").Append(EscapeJsonString(localization[token])).Append('"');
if (i < token_order.Count - 1) { sb.Append(','); }
sb.Append('\n');
}
sb.Append("}\n");
return sb.ToString();
}
// Non-ascii is deliberately passed through as-is, these files are written as UTF-8 rather than escaped.
private static string EscapeJsonString(string value)
{
StringBuilder sb = new StringBuilder(value.Length);
foreach (char c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < ' ') { sb.Append("\\u").Append(((int)c).ToString("x4")); }
else { sb.Append(c); }
break;
}
}
return sb.ToString();
}
/// <summary>
/// Resource names look like 'ImpactfulSkills.Localizations.English.json', the language is whatever segment
/// follows the localization folder. Derived rather than index based so nesting the folder cannot break it.
/// </summary>
private static string LanguageNameFromResource(string embeddedResource)
{
string[] segments = embeddedResource.Split('.');
int folder = Array.IndexOf(segments, LocalizationFolder);
if (folder < 0 || folder + 2 > segments.Length - 1) { return null; }
return segments[folder + 1];
}
/// <summary>
/// This reads an embedded file resource name, these are all resources packed into the DLL
/// </summary>
private static string ReadEmbeddedResourceFile(BaseUnityPlugin plugin, string filename)
{
using (var stream = plugin.GetType().Assembly.GetManifestResourceStream(filename))
{
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}