App-Idea 6: JSON to CSV Converter (Part 3)

In this article, we will wrap up the JSON to CSV Converter, which allows users to enter text in a simplified JSON format and converts it into a comma-separated value (CSV) format. This app-idea has more complex requirements, so we will cover this application in 3 parts:

  • In part 1, we focused on the basic conversion functionality that allowed users to type in JSON text and convert it to CSV format.
  • In part 2, we added functionality to copy the converted text to the clipboard, and load the JSON input from a local file.
  • In part 3 (this article), we will add functionality to save the converted text as a local CSV file.

In this article, we will add the ability to save the converted text to a local CSV file. This includes C# code to perform the operation and then create the file download via JavaScript interoperability.

We will continue to use the Json-Csv converter page and classes from Part 2 (please read that article first). You can find the source for the Blazor.AppIdeas.Converters on GitHub. And the running sample of the Blazor app online.

We will start by loading Blazor.AppIdeas.Converters solution from Part 2 into Visual Studio.

File Download JavaScript

To create a file as a download, we need to use JavaScript to do this via the browser. So, let’s create a new JavaScript file called ‘File.js’ in the Blazor.AppIdeas.Converters project and ‘./wwwroot/js’ folder.

window.file = {
    saveAsFile: function (filename, bytesBase64) {
        var link = document.createElement('a');
        link.download = filename;
        link.href = "data:application/octet-stream;base64," + bytesBase64;
        document.body.appendChild(link); // Needed for Firefox
        link.click();
        document.body.removeChild(link);
    }
}

The saveAsFile function takes a filename and the data to be saved in base64 encoding. This script:

  • Creates a link with the download filename.
  • The link href will contain the data for the file.
  • Then, we simulate a click on the link to start the file download process.
  • The browser will then show its download UI and save the file to the Downloads folder.
  • When this process completes, we remove the link we just created.

Next, we need to add a script link to this JavaScript file (line #27) to the main index.html file (in the wwwroot folder), so that the JavaScript function is available to our application.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Blazor AppIdeas - Converters</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="Blazor.AppIdeas.Converters.styles.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
</head>

<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
    <script src="js/Clipboard.js"></script>
    <script src="js/File.js"></script>
</body>

</html>

Update BrowserFileAdapter

Since we already have the BrowserFileAdapter class that loads data from local files, this class is a great place to our file save and download functionality as well. Let’s start by updating the IBrowserFileAdapter interface (in the Services folder).

using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.Services
{
    public interface IBrowserFileAdapter
    {
        public enum FileType
        {
            JSON = 0,
            CSV = 1
        }

        Task<string> ReadTextAsync(IBrowserFile file);

        Task SaveTextAsAsync(IJSRuntime js, string filename, FileType fileType, string data);
    }
}

First, we define a FileType enum for the two file types that we will support: JSON and CSV. Then, we define the SaveTextAsAsync method to manage the save and download operation.

Then, we need to implement that method in the BrowserFileAdapter class.

using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.Services
{
    public class BrowserFileAdapter : IBrowserFileAdapter
    {
        private const string _jsSaveAsFileMethod = "file.saveAsFile";
        private static readonly IList<string> _supportedMimeTypes = new List<string>
        {
            "application/json",
            "text/csv"
        };

        public async Task<string> ReadTextAsync(IBrowserFile browserFile)
        {
            _ = browserFile ?? throw new ArgumentNullException(nameof(browserFile));
            
            if (!_supportedMimeTypes.Contains(browserFile.ContentType))
            {
                throw new NotSupportedException(
                    $"File type ({browserFile.ContentType}) is not supported.");
            }

            using var file = browserFile.OpenReadStream();
            using var reader = new StreamReader(file, Encoding.UTF8);

            return await reader.ReadToEndAsync().ConfigureAwait(false);
        }

        public async Task SaveTextAsAsync(
            IJSRuntime jsRuntime,
            string filename,
            IBrowserFileAdapter.FileType fileType,
            string data)
        {
            _ = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
            if (string.IsNullOrEmpty(filename))
                throw new ArgumentNullException(nameof(filename));
            if (string.IsNullOrEmpty(data))
                throw new ArgumentNullException(nameof(data));

            var buffer = Encoding.UTF8.GetBytes(data);
            await jsRuntime.InvokeVoidAsync(
                    _jsSaveAsFileMethod,
                    CalculateFullFileName(filename, fileType),
                    Convert.ToBase64String(buffer));
        }

        private string CalculateFullFileName(
            string filename,
            IBrowserFileAdapter.FileType fileType)
        {
            var mimeType = _supportedMimeTypes[(int)fileType];
            var extension = mimeType.Split("/").Last();
            return $"{filename}.{extension}";
        }
    }
}

First, we define the saveAsFile method name as a constant (line #14).

Then, we implement the SaveTextAsAsync method (lines #37-54) to:

  • Validate the input parameters and throw exceptions for unexpected values.
  • Convert the data parameter from string to byte array, which is then converted to a base64 string. This is the format that the JavaScript function is expecting for the output file.
  • Calculate the full filename based on the specified base name and the fileType.
  • Finally, we call the IJSRuntime.InvokeVoidAsync method with all of this data to invoke the ‘file.saveAsFile’ function in our JavaScript above.

Finally, the CalculateFullFileName helper method creates the full filename with the appropriate extension based on the specified fileType and defined mime types.

JsonCsvConverter View Model

With our service method in place, we can add the DownloadConvertedText operation to the JsonCsvConverterViewModel. Let’s change the JsonCsvConverterViewModel (in the Blazor.AppIdeas.Converters project and ViewModels folder) to the following:

using Blazor.AppIdeas.Converters.Models;
using Blazor.AppIdeas.Converters.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.ViewModels
{
    public class JsonCsvConverterViewModel
    {
        private const string _jsCopyTextMethod = "clipboardCopy.copyText";
        private const string _convertedTextFilename = "ConvertedText";
        private readonly IJSRuntime _jsRuntime;
        private readonly IBrowserFileAdapter _browserFileAdapter;

        public JsonCsvConverterViewModel(
            IJSRuntime jsRuntime, IBrowserFileAdapter fileAdapter)
        {
            _jsRuntime = jsRuntime ?? 
                throw new ArgumentNullException(nameof(jsRuntime));

            _browserFileAdapter = fileAdapter ?? 
                throw new ArgumentNullException(nameof(fileAdapter));
        }

        public string SourceText { get; set; }

        public string ConvertedText { get; set; }

        public bool IsConvertedTextEmpty => string.IsNullOrEmpty(ConvertedText);

        public string ErrorMessage { get; private set; }

        public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

        public void ConvertToCsv()
        {
            try
            {
                ErrorMessage = null;

                var converter = new TextConverter(SourceText);
                ConvertedText = converter.JsonToCsv();
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot parse source text. {ex.Message}";
                ConvertedText = null;
            }
        }

        public void ClearAll()
        {
            SourceText = null;
            ConvertedText = null;
            ErrorMessage = null;
        }

        public async Task Copy() => await _jsRuntime.InvokeVoidAsync(
                                        _jsCopyTextMethod,
                                        ConvertedText);

        public async Task OpenInputFile(InputFileChangeEventArgs e)
        {
            try
            {
                ErrorMessage = null;

                _ = e ?? throw new ArgumentNullException(nameof(e));
                if (e.FileCount > 1)
                {
                    ErrorMessage = "Application does not support multiple file selection.";
                }
                else
                {
                    SourceText = await _browserFileAdapter.ReadTextAsync(e.File)
                                                          .ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot read file '{e?.File.Name}'. {ex.Message}";
            }
        }

        public async Task DownloadConvertedText()
        {
            try
            {
                ErrorMessage = null;

                await _browserFileAdapter.SaveTextAsAsync(
                                            _jsRuntime,
                                            _convertedTextFilename,
                                            IBrowserFileAdapter.FileType.CSV,
                                            ConvertedText)
                                         .ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot download converted text file. {ex.Message}";
            }
        }
    }
}

As with our other view model operations, the DownloadConvertedText method (lines #88-105) is responsible for:

  • Calling the IBrowserFileAdatper.SaveTextAsAsync method with the appropriate inputs (the global instance of the IJSRuntime, the base name for the converted text file, the desired file type, and the converted text).
  • And, handling any exceptions that happen throughout the process to show the user an appropriate error message.

JsonCsvConverter Page

Finally, it’s just a minor change to the JsonCsvConvert page (in the Pages folder) to bind the ‘Download File’ button’s onclick event to the JsonCsvConverterViewModel.DownloadConvertedText operation to hook up our UI element with our view model operation.

@page "/json-csv-convert"
@inject JsonCsvConverterViewModel vm

<div class="col-12 mt-3 pb-2 mb-3 container">
    <h3 class="mt-3">JSON-CSV Converter</h3>
    <hr />
    <EditForm class="form-row" Model=@vm>
        <div class="form-group col-lg-5 col-md-12">
            <InputTextArea id="convert-from-value" class="form-control"
                           placeholder="Type JSON or CSV..." rows="8"
                           @bind-Value=vm.SourceText />
            <div class="button-wrap">
                <label class="btn btn-secondary" for="convert-open-file">
                    <span class="oi oi-cloud-upload" aria-hidden="true" /> Load File
                </label>
                <InputFile id="convert-open-file" style="width: 100%"
                           @ref="fileRef" OnChange="vm.OpenInputFile"/>
            </div>
        </div>
        <div class="col-lg-2 col-md-12">
            <button id="btn-convert-csv"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-6 mb-lg-1 mb-3"
                    @onclick="vm.ConvertToCsv">
                To CSV
            </button>
            <button id="btn-clear"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-5 mb-lg-1 mb-3"
                    @onclick="vm.ClearAll">
                Clear
            </button>
        </div>
        <div class="form-group col-lg-5 col-12">
            <InputTextArea id="converted-value" class="form-control" rows="8"
                           readonly @bind-Value=vm.ConvertedText />
            <div>
                <button id="download-file" class="btn btn-outline-secondary text-nowrap"
                        style="width: 52%" disabled="@vm.IsConvertedTextEmpty"
                        @onclick="vm.DownloadConvertedText">
                    <span class="oi oi-cloud-download" aria-hidden="true" />
                    Download File
                </button>
                <button id="clipboard-copy" class="btn btn-outline-secondary"
                        style="width: 46.5%; margin-left: -2px"
                        disabled="@vm.IsConvertedTextEmpty" @onclick="vm.Copy">
                    <span class="oi oi-clipboard" aria-hidden="true" />
                    Copy
                </button>
            </div>
        </div>
        @if (vm.HasError)
        {
         <div class="alert alert-danger col-12 ml-1">
             <strong>Error:</strong> @vm.ErrorMessage
         </div>
        }
    </EditForm>
</div>

@code {
    public InputFile fileRef;
}

All of the required changes are done, so we can build and run (Ctrl + F5) our converter application again to see the results. If we convert some simple JSON and click the ‘Download File’ button, we should see the following download UI (in the Edge browser).

Fig 1 – Download Converted File

And by saving this file with the CSV extension, when we open the downloaded file, it will open with the app registered for that extension. In my case, it opens the CSV file in Excel.

Fig 2 – CSV in Excel

In conclusion, we built a fully functional JSON to CSV converter with a lot of helpful functionality to load input files, copy the converted results to the clipboard, and save those same results to a CSV file. In this particular lesson, we learned to:

  • Create and register JavaScript in our project.
  • Use JavaScript to build a download URL to save client-side data.
  • Use the IJSRuntime object to call JavaScript code in our project.
  • Convert string encoding to UTF8 and base64.

One thought on “App-Idea 6: JSON to CSV Converter (Part 3)

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s