3
\$\begingroup\$

I maintain a number of projects, and several of them feature Angular front-ends. These projects need to be available in multiple languages, at the very least French and Dutch and preferably also in English. In order to do that, Angular supports Internationalization and in our apps this means multiple JSON files named "en.json", "fr.json", "nl.json".

However, these often grow large over time, and sometimes developers forget to add a required key (plus its translated value) to every file.

I looked for a tool that could compare two Angular files, only considering the keys of those files (since the values are translations and thus different more often than not); however, I did not find such a tool. I also didn't find and code that would offer this functionality, and thus I decided to write my own.

Below is the result, a console app in .NET Framework 4.8:

internal class Program
{
    private static void Main()
    {
        new Runner().Execute();
    }
}

I try to keep Program.cs light and usually create a Runner class to handle the logic (this also allows me to avoid the bother with static methods etc.):

internal class Runner
{
    private static string rootFolder = @"C:\dev\website\src\assets\i18n";

    private Dictionary<string, List<string>> _keysByFileName;

    public void Execute()
    {
        Trace.TraceInformation("--------------------------------------------------------------");

        var fileNames = new[] { "en.json", "fr.json", "nl.json" };

        _keysByFileName = fileNames.ToDictionary(fileName => fileName, GetKeys);

        Compare(fileNames[0], fileNames[1]);
        Compare(fileNames[0], fileNames[2]);
        Compare(fileNames[1], fileNames[0]);
        Compare(fileNames[1], fileNames[2]);
        Compare(fileNames[2], fileNames[0]);
        Compare(fileNames[2], fileNames[1]);
    }

    private void Compare(string fileName1, string fileName2)
    {
        var itemsOnlyIn1 = _keysByFileName[fileName1].Except(_keysByFileName[fileName2]).ToList();

        if (itemsOnlyIn1.Any())
        {
            Trace.TraceWarning($"The following keys are present in {fileName1}"
                               + $" but missing from {fileName2}:"
                               + $" [{string.Join("], [", itemsOnlyIn1)}].");
        }
    }

    private List<string> GetKeys(string fileName)
    {
        var contents = File.ReadAllText(Path.Combine(rootFolder, fileName));
        
        var jObject = JObject.Parse(contents);

        var keys = GetKeys(jObject);
        
        return keys.Distinct().OrderBy(x => x).ToList();
    }

    private List<string> GetKeys(JObject jObject, List<string> parents = null)
    {
        if (parents == null)
        {
            parents = new List<string>();
        }

        var keys = new List<string>();

        foreach (var jProperty in jObject.Children<JProperty>())
        {
            keys.AddRange(GetKeys(jProperty, new List<string>(parents)));
        }

        return keys;
    }

    private List<string> GetKeys(JProperty jProperty, List<string> parents = null)
    {
        if (parents == null)
        {
            parents = new List<string>();
        }

        var keys = new List<string>();

        var name = jProperty.Name;
        parents.Add(name);

        var fullName = string.Join(".", parents);
        keys.Add(fullName);

        var children = jProperty.Children<JObject>().ToList();

        if (children.Any())
        {
            foreach (var child in children)
            {
                keys.AddRange(GetKeys(child, new List<string>(parents)));
            }
        }

        return keys;
    }
}

Note that I use NLog to convert the calls to Trace into log file entries by adding this to the App.config (combined with a custom NLog.config):

  <system.diagnostics>
    <trace autoflush="true">
      <listeners>
        <add name="MyNLogTraceListener" type="NLog.NLogTraceListener, NLog" />
      </listeners>
    </trace>
  </system.diagnostics>

The log entries look like this:

The following keys are present in en.json but missing from fr.json: [customerdirection], [metricrequestview.dialog.abort], [metricrequestview.dialog.close], [tooltips.btn.requestmetricsdeletion].

The following keys are present in en.json but missing from nl.json: [cirelation.create.application], [cirelation.create.environment], [tooltips.btn.requestmetricsdeletion].

The following keys are present in fr.json but missing from en.json: [applicationexternalcontactview.editexternalcontact], [customerServiceManagerApplicationLevel], [delete.failed.metricrequest], [delete.successful.environment].

The following keys are present in fr.json but missing from nl.json: [cirelation.create.application], [cirelation.create.environment], [delete.successful.environment].

The following keys are present in nl.json but missing from en.json: [applicationexternalcontactview.editexternalcontact], [customerServiceManagerApplicationLevel], [delete.failed.metricrequest].

The following keys are present in nl.json but missing from fr.json: [customerdirection], [metricrequestview.dialog.abort], [metricrequestview.dialog.close].

I'm least happy with the method private List<string> GetKeys(JProperty jProperty, List<string> parents = null): it feels clunky and unstructured.

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

Focussing on your GetKeys methods, I think you can simplify them by passing a path instead of a list of parents. There's also no point in checking Any before doing a foreach. Something like this should work (untested):

private List<string> GetKeys(JObject jObject, string path = null)
{
    var keys = new List<string>();

    foreach (var jProperty in jObject.Children<JProperty>())
    {
        keys.AddRange(GetKeys(jProperty, path));
    }

    return keys;
}

private List<string> GetKeys(JProperty jProperty, string path = null)
{
    var keys = new List<string>();
    var fullName = path == null ? jProperty.Name : $"{path}.{jProperty.Name}";
    keys.Add(fullName);

    var children = jProperty.Children<JObject>().ToList();

    foreach (var child in children)
    {
        keys.AddRange(GetKeys(child, fullName));
    }

    return keys;
}

I think you're doing this in a harder way than you need to. Look at your Execute method, how does it change with another file?

public void Execute()
{
    Trace.TraceInformation("--------------------------------------------------------------");

    var fileNames = new[] { "en.json", "fr.json", "nl.json", "new.json" };

    _keysByFileName = fileNames.ToDictionary(fileName => fileName, GetKeys);

    Compare(fileNames[0], fileNames[1]);
    Compare(fileNames[0], fileNames[2]);
    Compare(fileNames[0], fileNames[3]); // new
    Compare(fileNames[1], fileNames[0]);
    Compare(fileNames[1], fileNames[2]);
    Compare(fileNames[1], fileNames[3]); // new
    Compare(fileNames[2], fileNames[0]);
    Compare(fileNames[2], fileNames[1]);
    Compare(fileNames[2], fileNames[3]); // new
    Compare(fileNames[3], fileNames[0]); // new
    Compare(fileNames[3], fileNames[1]); // new
    Compare(fileNames[3], fileNames[2]); // new
}

Ouch, this isn't a scalable solution. Each new file adds many new comparisons.

I would try to do something like this, typed into CR so apologies for syntax/logic errors but I hope it illustrates the point:

private class KeyInformation
{
    public KeyInformation(string key)
    {
        Key = key;
    }

    public string Key { get; }
    public ISet<string> Files { get; } = new HashSet<T>();
}

public void Execute()
{
    var fileNames = new[] { "en.json", "fr.json", "nl.json" };
    var keyInformation = new Dictionary<string, KeyInformation>();
    foreach (var fileName in fileNames)
    {
        foreach (var key in GetKeys(fileName))
        {
            if (!keyInformation.TryGetValue(key, out var info))
            {
                info = new KeyInformation(key);
                keyInformation[key] = info;
            }
            // N.b. Add returns false if key already exists. You can use this to check for duplicate keys in the same file.
            info.Files.Add(fileName);
        }
    }

    var entriesToDoSomethingWith = keyInformation
        .Select(kvp => kvp.Value)
        .Where(i => i.Files.Count != fileNames.Count);

    foreach (var entry in entriesToDoSomethingWith)
    {
       // Print info for entry.Key
       // Present in entry.Files
       // Missing in fileNames.ExceptWith(entry.Files)
    }
}

Adding a new file now only changes the fileNames array :)

\$\endgroup\$
2
  • \$\begingroup\$ Yes, that bit is indeed not scalable, but this was a quick and dirty solution and I didn't want to bother with figuring out that logic. (For me it was always going to be those three files.) WRT using a path, yeah, that might be simpler. \$\endgroup\$
    – BCdotWEB
    Apr 21, 2022 at 9:48
  • 1
    \$\begingroup\$ That's fair enough. I am terrible at covering all the cases in combinations like that so I tend to avoid ever having to do it. The type of solution I proposed has become quite formulaic for me over the years - it seems to pop up a lot. \$\endgroup\$
    – RobH
    Apr 21, 2022 at 9:57
1
\$\begingroup\$

Focusing only to this List<string> GetKeys(JProperty jProperty, List<string> parents) method here is my rewritten version:

parents ??= new List<string>();
parents.Add(jProperty.Name);

var keys = new List<string>()
{
    string.Join(".", parents)
};

foreach (var child in jProperty.Children<JObject>())
{
    keys.AddRange(GetKeys(child, new List<string>(parents)));
}

return keys;

Let's see them one-by-one

if (parents == null)
{
    parents = new List<string>();
}
var name = jProperty.Name;
parents.Add(name);
  • Since name is used only once inside the Add call that's why I inlined it
  • I've also moved the parents.Add call right next to the optional parents initialization
var keys = new List<string>();

var fullName = string.Join(".", parents);
keys.Add(fullName); 
  • Since fullName is used only once inside the Add call that's why I inlined it
  • Rather than calling explicitly the Add method I took advantage of collection initialization
var children = jProperty.Children<JObject>().ToList();

if (children.Any())
{
    foreach (var child in children)
    {
        keys.AddRange(GetKeys(child, new List<string>(parents)));
    }
}
  • I think you do not need to call ToList on the Children call since it returns a JEnumerable which can be used in the foreach
  • The Any check is not needed because if the collection is empty then the foreach will simply skip the loop body
  • Since the Children could not return null that's why you don't need to do a null check, otherwise it would be advisable
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.