Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New tool JSON>Table/CSV/Excel #1003

Merged
merged 5 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public static class PredefinedCommonDataTypeNames
{
public const string Text = "text";
public const string Json = "json";
public const string JsonArray = "jsonArray";
public const string Yaml = "yaml";
public const string Base64Text = "base64Text";
public const string Base64Image = "base64Image";
Expand Down
9 changes: 9 additions & 0 deletions src/app/dev/DevToys.Tools/DevToys.Tools.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
<AutoGen>True</AutoGen>
<DependentUpon>GlobalStrings.resx</DependentUpon>
</Compile>
<Compile Update="Tools\Converters\JsonTable\JsonTableConverter.Designer.cs">
<DependentUpon>JsonTableConverter.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
<Compile Update="Tools\EncodersDecoders\Base64Image\Base64ImageEncoderDecoder.Designer.cs">
<DependentUpon>Base64ImageEncoderDecoder.resx</DependentUpon>
<DesignTime>True</DesignTime>
Expand Down Expand Up @@ -145,6 +150,10 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>GlobalStrings.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Tools\Converters\JsonTable\JsonTableConverter.resx">
<LastGenOutput>JsonTableConverter.Designer.cs</LastGenOutput>
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Tools\EncodersDecoders\Base64Image\Base64ImageEncoderDecoder.resx">
<LastGenOutput>Base64ImageEncoderDecoder.Designer.cs</LastGenOutput>
<Generator>ResXFileCodeGenerator</Generator>
Expand Down
129 changes: 129 additions & 0 deletions src/app/dev/DevToys.Tools/Helpers/JsonTableHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Data;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace DevToys.Tools.Helpers;

internal static class JsonTableHelper
{
internal static ConvertResult ConvertFromJson(string? text, CopyFormat format, CancellationToken cancellationToken)
{
char separator = format switch
{
CopyFormat.TSV => '\t',
CopyFormat.CSV => ',',
CopyFormat.FSV => ';',
_ => throw new NotSupportedException($"Unhandled {nameof(CopyFormat)}: {format}"),
};

JObject[]? array = ParseJsonArray(text);
if (array == null)
{
return new(null, "", ConvertResultError.NotJsonArray);
}

JObject[] flattened = array.Select(o => FlattenJsonObject(o)).ToArray();

string[] properties = flattened
.SelectMany(o => o.Properties())
.Select(p => p.Name)
.Distinct()
.ToArray();

if (properties.Length == 0)
{
return new(null, "", ConvertResultError.NoProperties);
}

var table = new DataGridContents(properties, new());

var clipboard = new StringBuilder();
clipboard.AppendLine(string.Join(separator, properties));

foreach (JObject obj in flattened)
{
cancellationToken.ThrowIfCancellationRequested();

string[] values = properties
.Select(p => obj[p]?.ToString() ?? "") // JObject indexer conveniently returns null for unknown properties
.ToArray();

table.Rows.Add(values);
clipboard.AppendLine(string.Join(separator, values));
}

return new(table, clipboard.ToString(), ConvertResultError.None);
}

internal record ConvertResult(DataGridContents? Data, string Text, ConvertResultError Error);

internal record DataGridContents(string[] Headings, List<string[]> Rows);

internal enum ConvertResultError { None, NotJsonArray, NoProperties }

internal enum CopyFormat
{
/// <summary>
/// Tab separated values
/// </summary>
TSV,

/// <summary>
/// Comma separated values
/// </summary>
CSV,

/// <summary>
/// Semicolon separated values (CSV French)
/// </summary>
FSV,
}

/// <summary>
/// Parse the text to an array of JObject, or null if the text does not represent a JSON array of objects.
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
private static JObject[]? ParseJsonArray(string? text)
{
try
{
// Coalesce to empty string to prevent ArgumentNullException (returns null instead).
var array = JsonConvert.DeserializeObject(text ?? "") as JArray;
return array?.Cast<JObject>().ToArray();
}
catch (JsonException)
{
return null;
}
catch (InvalidCastException)
{
return null;
}
}

internal static JObject FlattenJsonObject(JObject json)
{
var flattened = new JObject();

foreach (KeyValuePair<string, JToken?> kv in json)
{
if (kv.Value is JObject jobj)
{
// Flatten objects by prefixing their property names with the parent property name, underscore separated.
foreach (KeyValuePair<string, JToken?> kv2 in FlattenJsonObject(jobj))
{
flattened.Add($"{kv.Key}_{kv2.Key}", kv2.Value);
}
}
else if (kv.Value is JValue)
{
flattened[kv.Key] = kv.Value;
}
// else strip out any array values
}

return flattened;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;

namespace DevToys.Tools.SmartDetection;

/// <summary>
/// Detect non-empty JSON arrays.
/// </summary>
[Export(typeof(IDataTypeDetector))]
[DataTypeName(PredefinedCommonDataTypeNames.JsonArray, baseName: PredefinedCommonDataTypeNames.Json)]
internal sealed partial class JsonArrayDataTypeDetector : IDataTypeDetector
{
private readonly ILogger _logger;

[ImportingConstructor]
public JsonArrayDataTypeDetector()
{
_logger = this.Log();
}

public ValueTask<DataDetectionResult> TryDetectDataAsync(
object data,
DataDetectionResult? resultFromBaseDetector,
CancellationToken cancellationToken)
{
if (resultFromBaseDetector is not null
&& resultFromBaseDetector.Data is string dataString
&& IsJsonArray(dataString))
{
return ValueTask.FromResult(new DataDetectionResult(Success: true, Data: dataString));
}

return ValueTask.FromResult(DataDetectionResult.Unsuccessful);
}

private static bool IsJsonArray(string data)
{
try
{
var jtoken = JToken.Parse(data);
return jtoken is JArray ja && ja.Count > 0;
}
catch (Exception)
{
// Exception in parsing json. It likely mean the text isn't a JSON.
return false;
}
}

[LoggerMessage(1, LogLevel.Error, "An unexpected exception happened while trying to detect some JSON Array data.")]
partial void LogError(Exception ex);
}
Loading