-
-
Notifications
You must be signed in to change notification settings - Fork 43
Expand file tree
/
Copy pathProjectLocalizer.cs
More file actions
405 lines (372 loc) · 15.4 KB
/
Copy pathProjectLocalizer.cs
File metadata and controls
405 lines (372 loc) · 15.4 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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// Copyright (c) 2015-2023 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Xml.Linq;
using System.Xml.XPath;
using Microsoft.Build.Framework;
// ReSharper disable AssignNullToNotNullAttribute - System.IO is hypocritical in its null handling
namespace SIL.FieldWorks.Build.Tasks.Localization
{
public class ProjectLocalizer
{
private class ResourceInfo
{
public string ProjectFolder;
public List<string> ResXFiles;
public string RootNameSpace;
public string AssemblyName;
}
public ProjectLocalizer(string projectFolder, ProjectLocalizerOptions options)
{
ProjectFolder = projectFolder;
Options = options;
try
{
// ReSharper disable once ObjectCreationAsStatement - Instantiating only to see if it is possible
new CultureInfo(Options.Locale);
LocaleIsSupported = true;
}
catch
{
Console.WriteLine("Warning: Culture name {0} is not supported.", Options.Locale);
LocaleIsSupported = false;
}
}
private string ProjectFolder { get; }
private ProjectLocalizerOptions Options { get; }
private bool LocaleIsSupported { get; }
public void ProcessProject()
{
Options.LogMessage(MessageImportance.Low, "Processing project {0}", ProjectFolder);
var resourceInfo = GetResourceInfo(ProjectFolder, Options);
if (resourceInfo == null || resourceInfo.ResXFiles.Count == 0)
return; // nothing to localize; in particular we should NOT call al with no inputs.
if (Options.BuildSource)
CopySources(resourceInfo);
if (Options.BuildBinaries)
CreateResourceAssemblies(resourceInfo);
}
private static ResourceInfo GetResourceInfo(string projectFolder, ProjectLocalizerOptions options)
{
var projectFile = Directory.GetFiles(projectFolder, "*.csproj").FirstOrDefault(); // called only if there is exactly one.
if (projectFile == null)
{
options.LogError($"Tried to process {projectFolder} as a project but no .csproj file was found.");
return null;
}
var assemblyName = Path.GetFileNameWithoutExtension(projectFile);
var doc = XDocument.Load(projectFile);
var resxFiles = GetResXFiles(projectFolder);
if (resxFiles.Count == 0)
return null;
var rootNamespaceValue = doc.Descendants()
.FirstOrDefault(elem => elem.Name.LocalName == "RootNamespace");
var assemblyNameElement =
doc.Descendants().FirstOrDefault(elem => elem.Name.LocalName == "AssemblyName");
if (rootNamespaceValue == null)
{
var elements = doc.Descendants().Select(elem => elem.Name.LocalName);
options.LogError($"Can't find RootNamespace in {string.Concat(",", elements)}");
return null;
}
if (assemblyNameElement != null)
{
// The new .csproj format assumes that this is the same as the project name
// and specifies it only where it is different
assemblyName = assemblyNameElement.Value;
}
var resourceInfo = new ResourceInfo {
ProjectFolder = projectFolder,
ResXFiles = resxFiles,
RootNameSpace = rootNamespaceValue.Value,
AssemblyName = assemblyName
};
return resourceInfo;
}
private static List<string> GetResXFiles(string projectFolder)
{
var resxFiles = Directory.GetFiles(projectFolder, "*.resx").ToList();
// include child folders, one level down, which do not have their own .csproj.
foreach (var childFolder in Directory.GetDirectories(projectFolder))
{
if (Directory.GetFiles(childFolder, "*.csproj").Any())
continue;
resxFiles.AddRange(Directory.GetFiles(childFolder, "*.resx"));
}
return resxFiles;
}
/// <summary>
/// Copies localized resx files from the Localizations repository to the Output directory, adding the namespace to the filename
/// </summary>
private void CopySources(ResourceInfo resourceInfo)
{
foreach (var resxFile in resourceInfo.ResXFiles)
{
var localizedResxPath = GetLocalizedResxPath(resourceInfo, resxFile);
var localizedResxSourcePath = GetLocalizedResxSourcePath(resxFile);
Directory.CreateDirectory(Path.GetDirectoryName(localizedResxPath));
if (File.Exists(localizedResxSourcePath))
{
if (CheckResXForErrors(localizedResxSourcePath, resxFile))
continue;
File.Copy(localizedResxSourcePath, localizedResxPath, overwrite: true);
Options.LogMessage(MessageImportance.Low, "copying {0} resx to {1}", Options.Locale, localizedResxPath);
}
else
{
File.Copy(resxFile, localizedResxPath, overwrite: true);
Options.LogMessage(MessageImportance.Normal, $"copying original English resx to {localizedResxPath}");
Options.LogMessage(MessageImportance.Low, $"\t(could not find {localizedResxSourcePath})");
}
}
}
private string GetLocalizedResxPath(ResourceInfo resourceInfo, string resxPath)
{
var resxFileName = Path.GetFileNameWithoutExtension(resxPath);
// ReSharper disable once PossibleNullReferenceException
var partialDir = Path.GetDirectoryName(resxPath.Substring(Options.SrcFolder.Length + 1));
var projectPartialDir = resourceInfo.ProjectFolder.Substring(Options.SrcFolder.Length + 1);
var outputFolder = Path.Combine(Options.OutputFolder, Options.Locale, partialDir);
// This is the relative path from the project folder to the resx file folder.
// It needs to go into the file name if not empty, but with a dot instead of folder separator.
var subFolder = "";
if (partialDir.Length > projectPartialDir.Length)
subFolder = Path.GetFileName(partialDir) + ".";
var fileName = $"{resourceInfo.RootNameSpace}.{subFolder}{resxFileName}.{Options.Locale}.resx";
return Path.Combine(outputFolder, fileName);
}
internal string GetLocalizedResxSourcePath(string resxPath)
{
var resxFileName = Path.GetFileNameWithoutExtension(resxPath);
// ReSharper disable once PossibleNullReferenceException
var partialDir = Path.GetDirectoryName(resxPath.Substring(Options.RootDir.Length + 1));
var sourceFolder = Path.Combine(Options.CurrentLocaleDir, partialDir);
var fileName = $"{resxFileName}.{Options.Locale}.resx";
return Path.Combine(sourceFolder, fileName);
}
/// <returns><c>true</c> if the given ResX file has errors in string.Format variables</returns>
private bool CheckResXForErrors(string resxPath, string originalResxPath)
{
var originalElements = LocalizableElements(originalResxPath, out var comments);
var localizedElements = LocalizableElements(resxPath, out _);
var hasErrors = false;
//foreach (var key in localizedElements.Keys.Where(key => !originalElements.ContainsKey(key)))
//{
// Options.LogError($"{resxPath} contains a data element named '{key}' that is not present in the original file");
// hasErrors = true;
//}
//if (hasErrors || originalElements.Count != localizedElements.Count)
//{
// foreach (var key in originalElements.Keys.Where(key => !localizedElements.ContainsKey(key)))
// {
// Options.LogError($"{resxPath} is missing a data element named '{key}'");
// hasErrors = true;
// }
//}
foreach (var _ in localizedElements.Where(elt => Options.HasErrors(resxPath, elt.Value,
originalElements.TryGetValue(elt.Key, out var origElt) ? origElt : null,
comments.TryGetValue(elt.Key, out var origComment) ? origComment : null)))
{
hasErrors = true;
}
return hasErrors;
}
[SuppressMessage("ReSharper", "PossibleNullReferenceException", Justification = "R# doesn't recognize that x.Attribute('name') *is* checked for null")]
private Dictionary<string, string> LocalizableElements(string resxPath, out Dictionary<string, string> comments)
{
// (resx data elements that are localizable strings have no type attribute,
// have no mimetype attribute, and have a name that doesn't start with '>>' or '$this')
var localizableElements = XDocument.Load(resxPath).Root.XPathSelectElements("/*/data[not(@type) and not(@mimetype)]")
.Where(x => x.Attribute("name") != null &&
!x.Attribute("name").Value.StartsWith(">>") &&
!x.Attribute("name").Value.StartsWith("$this"));
var dict = new Dictionary<string, string>();
comments = new Dictionary<string, string>();
foreach (var element in localizableElements)
{
var key = element.Attribute("name").Value;
if (dict.ContainsKey(key))
{
Options.LogError($"Duplicate key {key} in {resxPath}");
}
else
{
dict.Add(key, element.Element("value")?.Value);
comments.Add(key, element.Element("comment")?.Value);
}
}
return dict;
}
private void CreateResourceAssemblies(ResourceInfo resourceInfo)
{
if (resourceInfo.ResXFiles.Count == 0)
return; // nothing to localize; in particular we should NOT call al with no inputs.
var embedResources = new List<EmbedInfo>();
var errors = 0;
foreach (var resxFile in resourceInfo.ResXFiles)
{
Options.LogMessage(MessageImportance.Low, "Creating assembly for {0}", resxFile);
var localizedResxPath = GetLocalizedResxPath(resourceInfo, resxFile);
var localizedResourcePath = Path.ChangeExtension(localizedResxPath, ".resources");
try
{
RunResGen(localizedResourcePath, localizedResxPath, Path.GetDirectoryName(resxFile));
embedResources.Add(new EmbedInfo(localizedResourcePath, Path.GetFileName(localizedResourcePath)));
}
catch (Exception ex)
{
Options.LogError(
$"Error occurred while processing {Path.GetFileName(resourceInfo.ProjectFolder)} for {Options.Locale}: {ex.Message}");
errors++;
}
Options.LogMessage(MessageImportance.Low, "Done creating assembly for {0}", resxFile);
}
if (errors == resourceInfo.ResXFiles.Count)
{
Options.LogMessage(MessageImportance.Low, $"All resx files for {Options.Locale} had errors; skipping AL");
return;
}
var resourceFileName = resourceInfo.AssemblyName + ".resources.dll";
var mainDllFolder = Path.Combine(Options.OutputFolder, Options.Config);
var localDllFolder = Path.Combine(mainDllFolder, Options.Locale);
var resourceDll = Path.Combine(localDllFolder, resourceFileName);
var culture = LocaleIsSupported ? Options.Locale : string.Empty;
try
{
Options.LogMessage(MessageImportance.Low, "Running AL for {0}", resourceDll);
RunAssemblyLinker(resourceDll, culture, Options.FileVersion,
Options.InformationVersion, Options.Version, embedResources);
Options.LogMessage(MessageImportance.Low, "Done running AL for {0}", resourceDll);
}
catch (Exception ex)
{
Options.LogError(
$"Error occurred while processing {Path.GetFileName(resourceInfo.ProjectFolder)} for {Options.Locale}: {ex.Message}");
}
}
/// <summary>
/// Run the AssemblyLinker to create a resource DLL with the specified path and other details containing the specified embedded resources.
/// </summary>
/// <param name="outputDllPath"></param>
/// <param name="culture"></param>
/// <param name="fileversion"></param>
/// <param name="productVersion"></param>
/// <param name="version"></param>
/// <param name="resources"></param>
protected virtual void RunAssemblyLinker(string outputDllPath, string culture,
string fileversion, string productVersion, string version, List<EmbedInfo> resources )
{
// Run assembly linker with the specified arguments
Directory.CreateDirectory(Path.GetDirectoryName(outputDllPath)); // make sure the directory in which we want to make it exists.
var fileName = IsUnix ? "al" : "al.exe";
var arguments = BuildLinkerArgs(outputDllPath, culture, fileversion,
productVersion, version, resources);
var exitCode = RunProcess(fileName, arguments, out var stdOutput);
if (exitCode != 0)
{
throw new ApplicationException(
$"Assembly linker returned error {exitCode} for {outputDllPath}.\n" +
$"Command line: {fileName} {arguments}\nOutput:\n{stdOutput}");
}
}
private static int RunProcess(string fileName, string arguments, out string stdOutput,
int timeout = 300000 /* 5 min */, string workdir = null)
{
var output = string.Empty;
using (var outputWaitHandle = new AutoResetEvent(false))
{
using (var process = new Process())
{
DataReceivedEventHandler outputHandler = (sender, e) =>
{
if (e.Data == null)
// ReSharper disable once AccessToDisposedClosure - we wait for the process to exit before disposing the handle
outputWaitHandle.Set();
else
output = e.Data;
};
try
{
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.FileName = fileName;
process.StartInfo.Arguments = arguments;
if (workdir != null)
process.StartInfo.WorkingDirectory = workdir;
process.OutputDataReceived += outputHandler;
process.Start();
process.BeginOutputReadLine();
process.WaitForExit(timeout);
stdOutput = output;
return process.ExitCode;
}
catch(Exception ex)
{
stdOutput = ex.Message;
return 1; // return a non-zero exit code to indicate failure
}
finally
{
outputWaitHandle.WaitOne(timeout);
process.OutputDataReceived -= outputHandler;
}
}
}
}
private static bool IsUnix => Environment.OSVersion.Platform == PlatformID.Unix;
protected static string BuildLinkerArgs(string outputDllPath, string culture, string fileversion,
string productVersion, string version, List<EmbedInfo> resources)
{
var builder = new StringBuilder();
builder.Append($" /out:\"{outputDllPath}\"");
foreach (var info in resources)
{
builder.Append($" /embed:{info.Resource},{info.Name}");
}
if (!string.IsNullOrEmpty(culture))
{
builder.Append($" /culture:{culture}");
}
builder.Append($" /fileversion:{fileversion}");
builder.Append($" /productversion:\"{productVersion}\"");
// may be something like "8.4.2 beta 2" (see LT-14436). Test does not really cover this.
builder.Append($" /version:{version}");
// Note: the old build process also set \target, but we want the default lib so don't need to be explicit.
// the old version also had support for controlling verbosity; we can add that if needed.
// May also want to set /config? The old version did not so I haven't.
return builder.ToString();
}
protected virtual void RunResGen(string outputResourcePath, string localizedResxPath,
string originalResxFolder)
{
var fileName = IsUnix ? "resgen" : "resgen.exe";
var arguments = $"\"{localizedResxPath}\" \"{outputResourcePath}\"";
if (!IsUnix)
{
// It needs to be able to reference the appropriate System.Drawing.dll and System.Windows.Forms.dll to make the conversion.
var clrFolder = RuntimeEnvironment.GetRuntimeDirectory();
var drawingPath = Path.Combine(clrFolder, "System.Drawing.dll");
var formsPath = Path.Combine(clrFolder, "System.Windows.Forms.dll");
arguments += $" /r:\"{drawingPath}\" /r:\"{formsPath}\"";
}
// Setting the working directory to the folder containing the ORIGINAL resx file allows us to find included files
// like FDO/Resources/Question.ico that the resx file refers to using relative paths.
var exitCode = RunProcess(fileName, arguments, out var stdOutput, workdir: originalResxFolder);
if (exitCode != 0)
{
throw new ApplicationException($"Resgen returned error {exitCode} for {localizedResxPath}.\n" +
$"Command line: {fileName} {arguments}\nOutput:\n{stdOutput}");
}
}
}
}