From 131350c8b40685de6537f2d8e3e405b8fb71d54a Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Fri, 6 Oct 2023 19:29:57 +0100 Subject: [PATCH 1/5] Added support for CLI and CSV import (WIP) --- .../MusicCatalogue.Api.csproj | 6 +- .../MusicCatalogue.Data.csproj | 4 +- .../CommandLine/CommandLineOption.cs | 15 ++ .../CommandLine/CommandLineOptionType.cs | 8 + .../CommandLine/CommandLineOptionValue.cs | 11 ++ .../DataExchange/FlattenedTrack.cs | 70 +++++++ .../TrackDataExchangeEventArgs.cs | 11 ++ src/MusicCatalogue.Entities/Database/Track.cs | 23 +-- .../Database/TrackBase.cs | 32 ++++ .../Exceptions/DuplicateOptionException.cs | 32 ++++ .../InvalidRecordFormatException.cs | 32 ++++ .../MalformedCommandLineException.cs | 31 +++ .../Exceptions/TooFewValuesException.cs | 31 +++ .../Exceptions/TooManyValuesException.cs | 36 ++++ .../UnrecognisedCommandLineOptionException.cs | 31 +++ .../Interfaces/ICsvImporter.cs | 7 + .../MusicCatalogue.Entities.csproj | 4 +- .../CommandLine/CommandLineParser.cs | 181 ++++++++++++++++++ .../DataExchange/CsvImporter.cs | 77 ++++++++ .../Factory/MusicCatalogueFactory.cs | 7 + .../MusicCatalogue.Logic.csproj | 4 +- .../MusicCatalogue.LookupTool.csproj | 6 +- src/MusicCatalogue.LookupTool/Program.cs | 80 +++++--- .../Properties/launchSettings.json | 2 +- .../CommandLineParserTest.cs | 108 +++++++++++ 25 files changed, 785 insertions(+), 64 deletions(-) create mode 100644 src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs create mode 100644 src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs create mode 100644 src/MusicCatalogue.Entities/CommandLine/CommandLineOptionValue.cs create mode 100644 src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs create mode 100644 src/MusicCatalogue.Entities/DataExchange/TrackDataExchangeEventArgs.cs create mode 100644 src/MusicCatalogue.Entities/Database/TrackBase.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/DuplicateOptionException.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/InvalidRecordFormatException.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/MalformedCommandLineException.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/TooFewValuesException.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/TooManyValuesException.cs create mode 100644 src/MusicCatalogue.Entities/Exceptions/UnrecognisedCommandLineOptionException.cs create mode 100644 src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs create mode 100644 src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs create mode 100644 src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs create mode 100644 src/MusicCatalogue.Tests/CommandLineParserTest.cs diff --git a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj index 45afaf0..82cb395 100644 --- a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj +++ b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj @@ -2,9 +2,9 @@ net7.0 - 1.1.0.0 - 1.1.0.0 - 1.1.0 + 1.2.0.0 + 1.2.0.0 + 1.2.0 enable enable diff --git a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj index be64a2d..c10a6be 100644 --- a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj +++ b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Data - 1.1.0.0 + 1.2.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.1.0.0 + 1.2.0.0 diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs new file mode 100644 index 0000000..f5f444d --- /dev/null +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.CommandLine +{ + [ExcludeFromCodeCoverage] + public class CommandLineOption + { + public CommandLineOptionType OptionType { get; set; } + public string Name { get; set; } = ""; + public string ShortName { get; set; } = ""; + public string Description { get; set; } = ""; + public int MinimumNumberOfValues { get; set; } = 0; + public int MaximumNumberOfValues { get; set; } = 0; + } +} diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs new file mode 100644 index 0000000..3c0c78b --- /dev/null +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs @@ -0,0 +1,8 @@ +namespace MusicCatalogue.Entities.CommandLine +{ + public enum CommandLineOptionType + { + Unknown, + Lookup, + } +} diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionValue.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionValue.cs new file mode 100644 index 0000000..b55be1a --- /dev/null +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionValue.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.CommandLine +{ + [ExcludeFromCodeCoverage] + public class CommandLineOptionValue + { + public CommandLineOption? Option { get; set; } + public List Values { get; private set; } = new List(); + } +} diff --git a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs new file mode 100644 index 0000000..d617a4b --- /dev/null +++ b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs @@ -0,0 +1,70 @@ +using MusicCatalogue.Entities.Database; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.DataExchange +{ + [ExcludeFromCodeCoverage] + public class FlattenedTrack : TrackBase + { + public const int ArtistField = 0; + public const int AlbumField = 1; + private const int GenreField = 2; + private const int ReleasedField = 3; + private const int CoverField = 4; + private const int TrackNumberField = 5; + private const int TitleField = 6; + private const int DurationField = 7; + + public const string CsvRecordPattern = @"^(""[a-zA-Z0-9-() \/']+"",){3}""[0-9]+"",("".*"",)""[0-9]+"",(""[a-zA-Z0-9-() \/']+"",)""[0-9]+\:[0-9]{2}""$"; + + public string ArtistName{ get; set; } = ""; + public string AlbumTitle { get; set; } = ""; + public string Genre { get; set; } = ""; + public int? Released { get; set; } + public string? CoverUrl { get; set; } = ""; + public int TrackNumber { get; set; } + public string Title { get; set; } = ""; + + /// + /// Create a representation of the flattened track in CSV format + /// + /// + public string ToCsv() + { + var representation = $"\"{ArtistName}\",\"{AlbumTitle}\",\"{Genre}\",\"{Released}\",\"{CoverUrl}\",\"{Title}\",\"{FormattedDuration()}\""; + return representation; + } + + /// + /// Create a flattened track record from a CSV string + /// + /// + /// + public static FlattenedTrack FromCsv(string record) + { + // Split the record into words + var words = record.Split(new string[] { "\",\"" }, StringSplitOptions.None); + + // Get the release date and cover URL, both of which may be NULL + int? releaseYear = !string.IsNullOrEmpty(words[ReleasedField]) ? int.Parse(words[ReleasedField]) : null; + string? coverUrl = !string.IsNullOrEmpty(words[CoverField]) ? words[CoverField] : null; + + // Split the duration on the ":" separator and convert to milliseconds + var durationWords = words[DurationField][..^1].Split(new string[] { ":" }, StringSplitOptions.None); + var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + 1000 * int.Parse(durationWords[1])); + + // Create a new "flattened" record containing artist, album and track details + return new FlattenedTrack + { + ArtistName = words[ArtistField][1..], + AlbumTitle = words[AlbumField], + Genre = words[GenreField], + Released = releaseYear, + CoverUrl = coverUrl, + TrackNumber = int.Parse(words[TrackNumberField]), + Title = words[TitleField], + Duration = durationMs + }; + } + } +} diff --git a/src/MusicCatalogue.Entities/DataExchange/TrackDataExchangeEventArgs.cs b/src/MusicCatalogue.Entities/DataExchange/TrackDataExchangeEventArgs.cs new file mode 100644 index 0000000..36ed096 --- /dev/null +++ b/src/MusicCatalogue.Entities/DataExchange/TrackDataExchangeEventArgs.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.DataExchange +{ + [ExcludeFromCodeCoverage] + public class TrackDataExchangeEventArgs : EventArgs + { + public long RecordCount { get; set; } + public FlattenedTrack? Track { get; set; } + } +} diff --git a/src/MusicCatalogue.Entities/Database/Track.cs b/src/MusicCatalogue.Entities/Database/Track.cs index 5714785..fe1554b 100644 --- a/src/MusicCatalogue.Entities/Database/Track.cs +++ b/src/MusicCatalogue.Entities/Database/Track.cs @@ -5,7 +5,7 @@ namespace MusicCatalogue.Entities.Database { [ExcludeFromCodeCoverage] - public class Track + public class Track : TrackBase { [Key] public int Id { get; set; } @@ -18,26 +18,5 @@ public class Track [Required] public string Title { get; set; } = ""; - - public int? Duration { get; set; } - - /// - /// Format the duration in MM:SS format - /// - /// - public string? FormattedDuration() - { - string? formatted = null; - - if (Duration != null) - { - int seconds = (Duration ?? 0) / 1000; - int minutes = seconds / 60; - seconds -= 60 * minutes; - formatted = $"{minutes:00}:{seconds:00}"; - } - - return formatted; - } } } diff --git a/src/MusicCatalogue.Entities/Database/TrackBase.cs b/src/MusicCatalogue.Entities/Database/TrackBase.cs new file mode 100644 index 0000000..769acce --- /dev/null +++ b/src/MusicCatalogue.Entities/Database/TrackBase.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MusicCatalogue.Entities.Database +{ + public abstract class TrackBase + { + public int? Duration { get; set; } + + /// + /// Format the duration in MM:SS format + /// + /// + public string? FormattedDuration() + { + string? formatted = null; + + if (Duration != null) + { + int seconds = (Duration ?? 0) / 1000; + int minutes = seconds / 60; + seconds -= 60 * minutes; + formatted = $"{minutes:00}:{seconds:00}"; + } + + return formatted; + } + } +} diff --git a/src/MusicCatalogue.Entities/Exceptions/DuplicateOptionException.cs b/src/MusicCatalogue.Entities/Exceptions/DuplicateOptionException.cs new file mode 100644 index 0000000..b2eaa3e --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/DuplicateOptionException.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + + [Serializable] + [ExcludeFromCodeCoverage] + public class DuplicateOptionException : Exception + { + public DuplicateOptionException() + { + } + + public DuplicateOptionException(string message) : base(message) + { + } + + public DuplicateOptionException(string message, Exception inner) : base(message, inner) + { + } + + protected DuplicateOptionException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Exceptions/InvalidRecordFormatException.cs b/src/MusicCatalogue.Entities/Exceptions/InvalidRecordFormatException.cs new file mode 100644 index 0000000..163382b --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/InvalidRecordFormatException.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class InvalidRecordFormatException : Exception + { + public InvalidRecordFormatException() + { + } + + public InvalidRecordFormatException(string message) : base(message) + { + } + + public InvalidRecordFormatException(string message, Exception inner) : base(message, inner) + { + } + + protected InvalidRecordFormatException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} + diff --git a/src/MusicCatalogue.Entities/Exceptions/MalformedCommandLineException.cs b/src/MusicCatalogue.Entities/Exceptions/MalformedCommandLineException.cs new file mode 100644 index 0000000..8338624 --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/MalformedCommandLineException.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class MalformedCommandLineException : Exception + { + public MalformedCommandLineException() + { + } + + public MalformedCommandLineException(string message) : base(message) + { + } + + public MalformedCommandLineException(string message, Exception inner) : base(message, inner) + { + } + + protected MalformedCommandLineException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Exceptions/TooFewValuesException.cs b/src/MusicCatalogue.Entities/Exceptions/TooFewValuesException.cs new file mode 100644 index 0000000..8f58997 --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/TooFewValuesException.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class TooFewValuesException : Exception + { + public TooFewValuesException() + { + } + + public TooFewValuesException(string message) : base(message) + { + } + + public TooFewValuesException(string message, Exception inner) : base(message, inner) + { + } + + protected TooFewValuesException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Exceptions/TooManyValuesException.cs b/src/MusicCatalogue.Entities/Exceptions/TooManyValuesException.cs new file mode 100644 index 0000000..35f3b3f --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/TooManyValuesException.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Threading.Tasks; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class TooManyValuesException : Exception + { + public TooManyValuesException() + { + } + + public TooManyValuesException(string message) : base(message) + { + } + + public TooManyValuesException(string message, Exception inner) : base(message, inner) + { + } + + protected TooManyValuesException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Exceptions/UnrecognisedCommandLineOptionException.cs b/src/MusicCatalogue.Entities/Exceptions/UnrecognisedCommandLineOptionException.cs new file mode 100644 index 0000000..9df0feb --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/UnrecognisedCommandLineOptionException.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class UnrecognisedCommandLineOptionException : Exception + { + public UnrecognisedCommandLineOptionException() + { + } + + public UnrecognisedCommandLineOptionException(string message) : base(message) + { + } + + public UnrecognisedCommandLineOptionException(string message, Exception inner) : base(message, inner) + { + } + + protected UnrecognisedCommandLineOptionException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} diff --git a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs new file mode 100644 index 0000000..f2a5bdb --- /dev/null +++ b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs @@ -0,0 +1,7 @@ +namespace MusicCatalogue.Entities.Interfaces +{ + public interface ICsvImporter + { + Task Import(string file); + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj index 95fb4a6..f322cef 100644 --- a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj +++ b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Entities - 1.1.0.0 + 1.2.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.1.0.0 + 1.2.0.0 diff --git a/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs new file mode 100644 index 0000000..b8aed6f --- /dev/null +++ b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs @@ -0,0 +1,181 @@ +using MusicCatalogue.Entities.CommandLine; +using MusicCatalogue.Entities.Exceptions; + +namespace MusicCatalogue.Logic.CommandLine +{ + public class CommandLineParser + { + private readonly List _options = new List(); + private readonly Dictionary _values = new Dictionary(); + + /// + /// Add an option to the available command line options + /// + /// + /// + /// + /// + /// + /// + public void Add(CommandLineOptionType optionType, string name, string shortName, string description, int minimumNumberOfValues, int maximumNumberOfValues) + { + // Check the option's not a duplicate + if (_options.Select(x => x.OptionType).Contains(optionType)) + { + throw new DuplicateOptionException($"Duplicate option: {optionType.ToString()}"); + } + + // Check the option name's not a duplicate + if (_options.Select(x => x.Name).Contains(name)) + { + throw new DuplicateOptionException($"Duplicate option name: {name}"); + } + + // Check the option short name's not a duplicate + if (_options.Select(x => x.ShortName).Contains(shortName)) + { + throw new DuplicateOptionException($"Duplicate option short name: {shortName}"); + } + + // Add the new option + _options.Add(new CommandLineOption + { + OptionType = optionType, + Name = name, + ShortName = shortName, + Description = description, + MinimumNumberOfValues = minimumNumberOfValues, + MaximumNumberOfValues = maximumNumberOfValues + }); + } + + /// + /// Parse a command line supplied as an enumerable list of strings + /// + /// + /// + public void Parse(IEnumerable args) + { + // Perform the intial parsing of the command line + BuildValueList(args); + + // Check that all arguments have the required number of values + CheckForMinimumValues(); + } + + /// + /// Return the valus for the specified option type + /// + /// + /// + public List? GetValues(CommandLineOptionType optionType) + { + List? values = null; + + if (_values.ContainsKey(optionType)) + { + values = _values[optionType].Values; + } + + return values; + } + + /// + /// Check that each supplied option has sufficient values with it + /// + /// + private void CheckForMinimumValues() + { + foreach (var value in _values.Values) + { + if (value.Values.Count < value.Option?.MinimumNumberOfValues) + { + var message = $"Too few values supplied for '{value.Option.Name}': Expected {value.Option.MinimumNumberOfValues}, got {value.Values.Count}"; + throw new TooFewValuesException(message); + } + } + } + + /// + /// Build the value list from the command line + /// + /// + /// + /// + private void BuildValueList(IEnumerable args) + { + CommandLineOptionValue? current = null; + + // Iterate over the command line arguments extracting options and associated values + foreach (string arg in args) + { + if (!string.IsNullOrEmpty(arg)) + { + if (arg.StartsWith("--")) + { + // Starts with "--" so this is the full name of an option. Create a new value + current = new CommandLineOptionValue + { + Option = FindOption(arg, true) + }; + + // Add the value to the list of all values + _values.Add(current.Option.OptionType, current); + } + else if (arg.StartsWith('-')) + { + // Starts with "-" so this is the short name of an option. Create a new value + current = new CommandLineOptionValue + { + Option = FindOption(arg, false) + }; + + // Add the value to the list of all values + _values.Add(current.Option.OptionType, current); + } + else if (current != null) + { + // No prefix but we have a current option so add this to the values for it, check that + // this doesn't exceed the maximum number of values for that option and raise an exception + // if it does + current.Values.Add(arg); + if (current.Values.Count > current.Option?.MaximumNumberOfValues) + { + var message = $"Too many values for '{current.Option.Name}' at '{arg}'"; + throw new TooManyValuesException(message); + } + } + else + { + // Doesn't start with a prefix indicating this is the start of a new option and + // we don't have a current option - malformed command line + var message = $"Malformed command line at '{arg}'"; + throw new MalformedCommandLineException(message); + } + } + } + } + + /// + /// Find an argument by name or short name + /// + /// + /// + /// + private CommandLineOption FindOption(string argument, bool byName) + { + // Look for the argument in the available options and, if found, return it + foreach (var option in _options) + { + if (byName && option.Name.Equals(argument) || !byName && option.ShortName.Equals(argument)) + { + return option; + } + } + + // Not found, so raise an exception + var message = $"Unrecognised command line option {argument}"; + throw new UnrecognisedCommandLineOptionException(message); + } + } +} diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs new file mode 100644 index 0000000..ca960d6 --- /dev/null +++ b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs @@ -0,0 +1,77 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Exceptions; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Factory; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.RegularExpressions; + +namespace MusicCatalogue.Logic.DataExchange +{ + [ExcludeFromCodeCoverage] + public partial class CsvImporter : ICsvImporter + { + private readonly MusicCatalogueFactory _factory; + + public EventHandler RecordImport; + +#pragma warning disable CS8618 + internal CsvImporter(MusicCatalogueFactory factory) + { + _factory = factory; + } +#pragma warning restore CS8618 + + /// + /// Regular expression used to validate CSV record format + /// + /// + [GeneratedRegex("^(\"[a-zA-Z0-9-() \\/']+\",){3}\"[0-9]+\",(\".*\",)\"[0-9]+\",(\"[a-zA-Z0-9-() \\/']+\",)\"[0-9]+\\:[0-9]{2}\"$", RegexOptions.Compiled)] + private static partial Regex RecordFormatRegex(); + + /// + /// Import the contents of the specified CSV file + /// + /// + public async Task Import(string file) + { + Regex regex = RecordFormatRegex(); + + using (StreamReader reader = new(file, Encoding.UTF8)) + { + int count = 0; + while (!reader.EndOfStream) + { + // Read the next line and make sure it's got some content + var line = reader.ReadLine(); + if (!string.IsNullOrEmpty(line)) + { + // Increment the record cound + count++; + if (count > 1) + { + // Check the line matches the pattern required for successful import. Note that this does + // not allow commas in the artist name, title or track name + bool matches = regex.Matches(line!).Any(); + if (!matches) + { + Console.WriteLine(line); + string message = $"Invalid record format at line {count} of {file}"; + throw new InvalidRecordFormatException(message); + } + + // Inflate the CSV record to a track and store it in the database + FlattenedTrack track = FlattenedTrack.FromCsv(line); + var artist = await _factory.Artists.AddAsync(track.ArtistName); + var album = await _factory.Albums.AddAsync(artist.Id, track.AlbumTitle, track.Released, track.Genre, track.CoverUrl); + await _factory.Tracks.AddAsync(album.Id, track.Title, track.TrackNumber, track.Duration); + + RecordImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); + } + } + } + } + } + + } +} diff --git a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs index 4c79de5..d44eea8 100644 --- a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs @@ -1,6 +1,8 @@ using MusicCatalogue.Data; using MusicCatalogue.Entities.Interfaces; using MusicCatalogue.Logic.Database; +using MusicCatalogue.Logic.DataExchange; +using System.Diagnostics.CodeAnalysis; namespace MusicCatalogue.Logic.Factory { @@ -10,18 +12,23 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory private readonly Lazy _albums; private readonly Lazy _tracks; private readonly Lazy _users; + private readonly Lazy _importer; public IArtistManager Artists { get { return _artists.Value; } } public IAlbumManager Albums { get { return _albums.Value; } } public ITrackManager Tracks { get { return _tracks.Value; } } public IUserManager Users { get { return _users.Value; } } + [ExcludeFromCodeCoverage] + public ICsvImporter Importer { get { return _importer.Value; } } + public MusicCatalogueFactory(MusicCatalogueDbContext context) { _artists = new Lazy(() => new ArtistManager(context)); _albums = new Lazy(() => new AlbumManager(context)); _tracks = new Lazy(() => new TrackManager(context)); _users = new Lazy(() => new UserManager(context)); + _importer =new Lazy(() => new CsvImporter(this)); } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj index 83e3ca6..09c8d7f 100644 --- a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj +++ b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Logic - 1.1.0.0 + 1.2.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.1.0.0 + 1.2.0.0 diff --git a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj index d1c6683..5141b65 100644 --- a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj +++ b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj @@ -3,9 +3,9 @@ Exe net7.0 - 1.1.0.0 - 1.1.0.0 - 1.1.0 + 1.2.0.0 + 1.2.0.0 + 1.2.0 enable enable diff --git a/src/MusicCatalogue.LookupTool/Program.cs b/src/MusicCatalogue.LookupTool/Program.cs index b042841..5257df2 100644 --- a/src/MusicCatalogue.LookupTool/Program.cs +++ b/src/MusicCatalogue.LookupTool/Program.cs @@ -1,35 +1,45 @@ using MusicCatalogue.Data; +using MusicCatalogue.Entities.CommandLine; using MusicCatalogue.Entities.Config; using MusicCatalogue.Entities.Logging; using MusicCatalogue.Logic.Api; using MusicCatalogue.Logic.Api.TheAudioDB; using MusicCatalogue.Logic.Collection; +using MusicCatalogue.Logic.CommandLine; using MusicCatalogue.Logic.Config; +using MusicCatalogue.Logic.Database; using MusicCatalogue.Logic.Factory; using MusicCatalogue.Logic.Logging; using System.Diagnostics; -using System.Globalization; using System.Reflection; namespace MusicCatalogue.LookupPoC { public static class Program { + private static CommandLineParser? _parser= null; + private static MusicApplicationSettings? _settings = null; + private static FileLogger? _logger = null; + private static MusicCatalogueFactory? _factory = null; + + /// + /// Application entry point + /// + /// + /// public static async Task Main(string[] args) { - // Check the arguments are OK - if (args.Length != 2) - { - Console.WriteLine($"Usage: {System.AppDomain.CurrentDomain.FriendlyName} \"artist\" \"album title\""); - return; - } + // Parse the command line + _parser = new(); + _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + _parser.Parse(args); - // Read the application config file - var settings = new MusicCatalogueConfigReader().Read("appsettings.json"); + // Read the application settings + _settings = new MusicCatalogueConfigReader().Read("appsettings.json"); // Configure the log file - var logger = new FileLogger(); - logger.Initialise(settings!.LogFile, settings.MinimumLogLevel); + _logger = new FileLogger(); + _logger.Initialise(_settings!.LogFile, _settings.MinimumLogLevel); // Get the version number and application title Assembly assembly = Assembly.GetExecutingAssembly(); @@ -37,13 +47,33 @@ public static async Task Main(string[] args) var title = $"Music Catalogue Lookup Tool v{info.FileVersion}"; // Log the startup messages - logger.LogMessage(Severity.Info, new string('=', 80)); - logger.LogMessage(Severity.Info, title); + Console.WriteLine($"{title}\n"); + _logger.LogMessage(Severity.Info, new string('=', 80)); + _logger.LogMessage(Severity.Info, title); + // Configure the business logic factory + var context = new MusicCatalogueDbContextFactory().CreateDbContext(Array.Empty()); + _factory = new MusicCatalogueFactory(context); + + // If this is a lookup, look up the album details + var values = _parser.GetValues(CommandLineOptionType.Lookup); + if (values != null) + { + await LookupAlbum(values[0], values[1]); + } + } + + /// + /// Lookup an album given the artist name and album title + /// + /// + /// + private static async Task LookupAlbum(string artistName, string albumTitle) + { // Get the API key and the URLs for the album and track lookup endpoints - var key = settings.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; - var albumsEndpoint = settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; - var tracksEndpoint = settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; + var key = _settings!.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; + var albumsEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; + var tracksEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; // Convert the URL into a URI instance that will expose the host name - this is needed // to set up the client headers @@ -54,26 +84,18 @@ public static async Task Main(string[] args) client.AddHeader("X-RapidAPI-Key", key); client.AddHeader("X-RapidAPI-Host", uri.Host); - // Configure the database management classes - var context = new MusicCatalogueDbContextFactory().CreateDbContext(Array.Empty()); - var factory = new MusicCatalogueFactory(context); - // Configure the APIs - var albumsApi = new TheAudioDBAlbumsApi(logger, client, albumsEndpoint); - var tracksApi = new TheAudioDBTracksApi(logger, client, tracksEndpoint); - var lookupManager = new AlbumLookupManager(logger, albumsApi, tracksApi, factory); + var albumsApi = new TheAudioDBAlbumsApi(_logger, client, albumsEndpoint); + var tracksApi = new TheAudioDBTracksApi(_logger, client, tracksEndpoint); + var lookupManager = new AlbumLookupManager(_logger, albumsApi, tracksApi, _factory); // Lookup the album and its tracks - var album = await lookupManager.LookupAlbum(args[0], args[1]); + var album = await lookupManager.LookupAlbum(artistName, albumTitle); if (album != null) { - // Convert the artist name to title case for display - TextInfo textInfo = new CultureInfo("en-GB", false).TextInfo; - var artistName = textInfo.ToTitleCase(args[0]); - // Dump the album details Console.WriteLine($"Title: {album.Title}"); - Console.WriteLine($"Artist: {artistName}"); + Console.WriteLine($"Artist: {StringCleaner.Clean(artistName)}"); Console.WriteLine($"Released: {album.Released}"); Console.WriteLine($"Genre: {album.Genre}"); Console.WriteLine($"Cover: {album.CoverUrl}"); diff --git a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json index 0225cab..784481f 100644 --- a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json +++ b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "MusicCatalogue.LookupTool": { "commandName": "Project", - "commandLineArgs": "\"Dire Straits\" \"Brothers In Arms\"" + "commandLineArgs": "--lookup \"Dire Straits\" \"Brothers In Arms\"" } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Tests/CommandLineParserTest.cs b/src/MusicCatalogue.Tests/CommandLineParserTest.cs new file mode 100644 index 0000000..d8bc26d --- /dev/null +++ b/src/MusicCatalogue.Tests/CommandLineParserTest.cs @@ -0,0 +1,108 @@ +using MusicCatalogue.Entities.CommandLine; +using MusicCatalogue.Entities.Exceptions; +using MusicCatalogue.Logic.CommandLine; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Tests +{ + [ExcludeFromCodeCoverage] + [TestClass] + public class CommandLineParserTest + { + private CommandLineParser? _parser; + + [TestInitialize] + public void TestInitialise() + { + _parser = new CommandLineParser(); + _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + } + + [TestMethod] + public void ValidUsingNamesTest() + { + string[] args = new string[]{ "--lookup", "The Beatles", "Let It Be" }; + _parser!.Parse(args); + + var values = _parser?.GetValues(CommandLineOptionType.Lookup); + Assert.IsNotNull(values); + Assert.AreEqual(2, values.Count); + Assert.AreEqual("The Beatles", values[0]); + Assert.AreEqual("Let It Be", values[1]); + } + + [TestMethod] + public void ValidUsingShortNamesTest() + { + string[] args = new string[] { "-l", "The Beatles", "Let It Be" }; + _parser!.Parse(args); + + var values = _parser?.GetValues(CommandLineOptionType.Lookup); + Assert.IsNotNull(values); + Assert.AreEqual(2, values.Count); + Assert.AreEqual("The Beatles", values[0]); + Assert.AreEqual("Let It Be", values[1]); + } + + [TestMethod] + [ExpectedException(typeof(TooFewValuesException))] + public void TooFewArgumentsFailsTest() + { + string[] args = new string[] { "-l", "The Beatles" }; + _parser!.Parse(args); + } + + [TestMethod] + [ExpectedException(typeof(TooManyValuesException))] + public void TooManyArgumentsFailsTest() + { + string[] args = new string[] { "-l", "The Beatles", "Let It Be", "Extra Argument" }; + _parser!.Parse(args); + } + + [TestMethod] + [ExpectedException(typeof(UnrecognisedCommandLineOptionException))] + public void UnrecognisedOptionNameFailsTest() + { + string[] args = new string[] { "--oops", "The Beatles", "Let It Be" }; + _parser!.Parse(args); + } + + [TestMethod] + [ExpectedException(typeof(UnrecognisedCommandLineOptionException))] + public void UnrecognisedOptionShortNameFailsTest() + { + string[] args = new string[] { "-o", "The Beatles", "Let It Be" }; + _parser!.Parse(args); + } + + [TestMethod] + [ExpectedException(typeof(MalformedCommandLineException))] + public void MalformedCommandLineFailsTest() + { + string[] args = new string[] { "The Beatles", "--lookup", "Let It Be" }; + _parser!.Parse(args); + } + + [TestMethod] + [ExpectedException(typeof(DuplicateOptionException))] + public void DuplicateOptionTypeFailsTest() + { + _parser!.Add(CommandLineOptionType.Lookup, "--other-lookup", "-ol", "Duplicate option type", 2, 2); + } + + [TestMethod] + [ExpectedException(typeof(DuplicateOptionException))] + public void DuplicateOptionNameFailsTest() + { + _parser!.Add(CommandLineOptionType.Unknown, "--lookup", "-ol", "Duplicate option name", 2, 2); + } + + [TestMethod] + [ExpectedException(typeof(DuplicateOptionException))] + public void DuplicateOptionShortNameFailsTest() + { + _parser!.Add(CommandLineOptionType.Unknown, "--other-lookup", "-l", "Duplicate option shortname", 2, 2); + } + } +} From a288edda6cc8c0bcb33dd2af3dc5a69565e1b0c4 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Fri, 6 Oct 2023 22:01:43 +0100 Subject: [PATCH 2/5] Added CSV import support --- .../CommandLine/CommandLineOption.cs | 1 + .../CommandLine/CommandLineOptionType.cs | 1 + .../DataExchange/FlattenedTrack.cs | 2 +- .../Exceptions/MultipleOperationsException.cs | 32 +++++++ .../Interfaces/ICsvImporter.cs | 5 +- .../Interfaces/IMusicCatalogueFactory.cs | 1 + .../CommandLine/CommandLineParser.cs | 22 ++++- .../DataExchange/CsvImporter.cs | 8 +- .../Logic/AlbumLookup.cs | 81 ++++++++++++++++ .../Logic/DataImport.cs | 58 ++++++++++++ src/MusicCatalogue.LookupTool/Program.cs | 93 ++++--------------- .../Properties/launchSettings.json | 2 +- .../CommandLineParserTest.cs | 17 +++- 13 files changed, 237 insertions(+), 86 deletions(-) create mode 100644 src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs create mode 100644 src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs create mode 100644 src/MusicCatalogue.LookupTool/Logic/DataImport.cs diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs index f5f444d..eb1fdd8 100644 --- a/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs @@ -6,6 +6,7 @@ namespace MusicCatalogue.Entities.CommandLine public class CommandLineOption { public CommandLineOptionType OptionType { get; set; } + public bool IsOperation { get; set; } public string Name { get; set; } = ""; public string ShortName { get; set; } = ""; public string Description { get; set; } = ""; diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs index 3c0c78b..4c0442e 100644 --- a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs @@ -4,5 +4,6 @@ public enum CommandLineOptionType { Unknown, Lookup, + Import, } } diff --git a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs index d617a4b..30c49ca 100644 --- a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs +++ b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs @@ -51,7 +51,7 @@ public static FlattenedTrack FromCsv(string record) // Split the duration on the ":" separator and convert to milliseconds var durationWords = words[DurationField][..^1].Split(new string[] { ":" }, StringSplitOptions.None); - var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + 1000 * int.Parse(durationWords[1])); + var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + int.Parse(durationWords[1])); // Create a new "flattened" record containing artist, album and track details return new FlattenedTrack diff --git a/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs b/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs new file mode 100644 index 0000000..20d777d --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class MultipleOperationsException : Exception + { + public MultipleOperationsException() + { + } + + public MultipleOperationsException(string message) : base(message) + { + } + + public MultipleOperationsException(string message, Exception inner) : base(message, inner) + { + } + + protected MultipleOperationsException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} + diff --git a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs index f2a5bdb..e43a5d0 100644 --- a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs +++ b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs @@ -1,7 +1,10 @@ -namespace MusicCatalogue.Entities.Interfaces +using MusicCatalogue.Entities.DataExchange; + +namespace MusicCatalogue.Entities.Interfaces { public interface ICsvImporter { + event EventHandler? TrackImport; Task Import(string file); } } \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs index cc1e71f..2ac4b72 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs @@ -6,5 +6,6 @@ public interface IMusicCatalogueFactory IArtistManager Artists { get; } ITrackManager Tracks { get; } IUserManager Users { get; } + ICsvImporter Importer { get; } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs index b8aed6f..5f0cd17 100644 --- a/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs +++ b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs @@ -12,12 +12,13 @@ public class CommandLineParser /// Add an option to the available command line options /// /// + /// /// /// /// /// /// - public void Add(CommandLineOptionType optionType, string name, string shortName, string description, int minimumNumberOfValues, int maximumNumberOfValues) + public void Add(CommandLineOptionType optionType, bool isOperation, string name, string shortName, string description, int minimumNumberOfValues, int maximumNumberOfValues) { // Check the option's not a duplicate if (_options.Select(x => x.OptionType).Contains(optionType)) @@ -41,6 +42,7 @@ public void Add(CommandLineOptionType optionType, string name, string shortName, _options.Add(new CommandLineOption { OptionType = optionType, + IsOperation = isOperation, Name = name, ShortName = shortName, Description = description, @@ -61,6 +63,11 @@ public void Parse(IEnumerable args) // Check that all arguments have the required number of values CheckForMinimumValues(); + + // Check that there's only one argument that defines an operation. For example, + // looking up an album's details is one operation, importing a CSV file is another. + // Both can't be supplied at the same time + CheckForSingleOperation(); } /// @@ -96,6 +103,19 @@ private void CheckForMinimumValues() } } + /// + /// Check there's only one operation specified on the command line + /// + private void CheckForSingleOperation() + { + IEnumerable options = _options.Where(x => _values!.ContainsKey(x.OptionType) && x.IsOperation); + if (options!.Count() > 1) + { + var message = $"Command line specifies multiple operations: {string.Join(", ", options.Select(x => x.ToString()))}"; + throw new MultipleOperationsException(message); + } + } + /// /// Build the value list from the command line /// diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs index ca960d6..8c770d8 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs @@ -13,7 +13,7 @@ public partial class CsvImporter : ICsvImporter { private readonly MusicCatalogueFactory _factory; - public EventHandler RecordImport; + public event EventHandler? TrackImport; #pragma warning disable CS8618 internal CsvImporter(MusicCatalogueFactory factory) @@ -60,13 +60,15 @@ public async Task Import(string file) throw new InvalidRecordFormatException(message); } - // Inflate the CSV record to a track and store it in the database + // Inflate the CSV record to a track and save the artist FlattenedTrack track = FlattenedTrack.FromCsv(line); var artist = await _factory.Artists.AddAsync(track.ArtistName); + + // See if the album exists var album = await _factory.Albums.AddAsync(artist.Id, track.AlbumTitle, track.Released, track.Genre, track.CoverUrl); await _factory.Tracks.AddAsync(album.Id, track.Title, track.TrackNumber, track.Duration); - RecordImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); + TrackImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); } } } diff --git a/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs b/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs new file mode 100644 index 0000000..8f6caa8 --- /dev/null +++ b/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs @@ -0,0 +1,81 @@ +using MusicCatalogue.Entities.Config; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Api; +using MusicCatalogue.Logic.Api.TheAudioDB; +using MusicCatalogue.Logic.Collection; +using MusicCatalogue.Logic.Database; + +namespace MusicCatalogue.LookupTool.Logic +{ + internal class AlbumLookup + { + private readonly IMusicLogger _logger; + private readonly MusicApplicationSettings _settings; + private readonly IMusicCatalogueFactory _factory; + + public AlbumLookup(IMusicLogger logger, IMusicCatalogueFactory factory, MusicApplicationSettings settings) + { + _logger = logger; + _settings = settings; + _factory = factory; + } + + /// + /// Lookup an album given the artist name and album title + /// + /// + /// + public async Task LookupAlbum(string artistName, string albumTitle) + { + // Get the API key and the URLs for the album and track lookup endpoints + var key = _settings!.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; + var albumsEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; + var tracksEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; + + // Convert the URL into a URI instance that will expose the host name - this is needed + // to set up the client headers + var uri = new Uri(albumsEndpoint); + + // Configure an HTTP client + var client = MusicHttpClient.Instance; + client.AddHeader("X-RapidAPI-Key", key); + client.AddHeader("X-RapidAPI-Host", uri.Host); + + // Configure the APIs + var albumsApi = new TheAudioDBAlbumsApi(_logger, client, albumsEndpoint); + var tracksApi = new TheAudioDBTracksApi(_logger, client, tracksEndpoint); + var lookupManager = new AlbumLookupManager(_logger, albumsApi, tracksApi, _factory); + + // Lookup the album and its tracks + var album = await lookupManager.LookupAlbum(artistName, albumTitle); + if (album != null) + { + // Dump the album details + Console.WriteLine($"Title: {album.Title}"); + Console.WriteLine($"Artist: {StringCleaner.Clean(artistName)}"); + Console.WriteLine($"Released: {album.Released}"); + Console.WriteLine($"Genre: {album.Genre}"); + Console.WriteLine($"Cover: {album.CoverUrl}"); + Console.WriteLine(); + + // Dump the track list + if ((album.Tracks != null) && (album.Tracks.Count > 0)) + { + foreach (var track in album.Tracks) + { + Console.WriteLine($"{track.Number} : {track.Title}, {track.FormattedDuration()}"); + } + Console.WriteLine(); + } + else + { + Console.WriteLine("No tracks found"); + } + } + else + { + Console.WriteLine("Album details not found"); + } + } + } +} diff --git a/src/MusicCatalogue.LookupTool/Logic/DataImport.cs b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs new file mode 100644 index 0000000..b000318 --- /dev/null +++ b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs @@ -0,0 +1,58 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Entities.Logging; + +namespace MusicCatalogue.LookupTool.Logic +{ + internal class DataImport + { + private readonly IMusicLogger _logger; + private readonly IMusicCatalogueFactory _factory; + + public DataImport(IMusicLogger logger, IMusicCatalogueFactory factory) + { + _logger = logger; + _factory = factory; + } + + /// + /// Import the data held in the specified CSV file + /// + /// + public void Import(string file) + { + _logger.LogMessage(Severity.Info, $"Importing {file} ..."); + + try + { + // Register a handler for the "track imported" event and import the file + _factory.Importer.TrackImport += OnTrackImported; + _factory.Importer.Import(file); + } + catch (Exception ex) + { + Console.WriteLine($"Import error: {ex.Message}"); + _logger.LogMessage(Severity.Info, $"Import error: {ex.Message}"); + _logger.LogException(ex); + } + finally + { + // Un-register the event handler + _factory.Importer.TrackImport -= OnTrackImported; + } + } + + /// + /// Handler called when a track is imported + /// + /// + /// + public void OnTrackImported(object? sender, TrackDataExchangeEventArgs e) + { + if (e.Track != null) + { + Console.WriteLine($"Imported {e.Track.ArtistName} {e.Track.AlbumTitle} {e.Track.TrackNumber} {e.Track.Title}"); + } + } + } +} diff --git a/src/MusicCatalogue.LookupTool/Program.cs b/src/MusicCatalogue.LookupTool/Program.cs index 5257df2..353d5dc 100644 --- a/src/MusicCatalogue.LookupTool/Program.cs +++ b/src/MusicCatalogue.LookupTool/Program.cs @@ -2,14 +2,11 @@ using MusicCatalogue.Entities.CommandLine; using MusicCatalogue.Entities.Config; using MusicCatalogue.Entities.Logging; -using MusicCatalogue.Logic.Api; -using MusicCatalogue.Logic.Api.TheAudioDB; -using MusicCatalogue.Logic.Collection; using MusicCatalogue.Logic.CommandLine; using MusicCatalogue.Logic.Config; -using MusicCatalogue.Logic.Database; using MusicCatalogue.Logic.Factory; using MusicCatalogue.Logic.Logging; +using MusicCatalogue.LookupTool.Logic; using System.Diagnostics; using System.Reflection; @@ -17,11 +14,6 @@ namespace MusicCatalogue.LookupPoC { public static class Program { - private static CommandLineParser? _parser= null; - private static MusicApplicationSettings? _settings = null; - private static FileLogger? _logger = null; - private static MusicCatalogueFactory? _factory = null; - /// /// Application entry point /// @@ -30,16 +22,17 @@ public static class Program public static async Task Main(string[] args) { // Parse the command line - _parser = new(); - _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); - _parser.Parse(args); + CommandLineParser parser = new(); + parser.Add(CommandLineOptionType.Lookup, true, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + parser.Add(CommandLineOptionType.Import, true, "--import", "-i", "Import data from a CSV format file", 1, 1); + parser.Parse(args); // Read the application settings - _settings = new MusicCatalogueConfigReader().Read("appsettings.json"); + MusicApplicationSettings? settings = new MusicCatalogueConfigReader().Read("appsettings.json"); // Configure the log file - _logger = new FileLogger(); - _logger.Initialise(_settings!.LogFile, _settings.MinimumLogLevel); + FileLogger logger = new FileLogger(); + logger.Initialise(settings!.LogFile, settings.MinimumLogLevel); // Get the version number and application title Assembly assembly = Assembly.GetExecutingAssembly(); @@ -48,77 +41,27 @@ public static async Task Main(string[] args) // Log the startup messages Console.WriteLine($"{title}\n"); - _logger.LogMessage(Severity.Info, new string('=', 80)); - _logger.LogMessage(Severity.Info, title); + logger.LogMessage(Severity.Info, new string('=', 80)); + logger.LogMessage(Severity.Info, title); // Configure the business logic factory var context = new MusicCatalogueDbContextFactory().CreateDbContext(Array.Empty()); - _factory = new MusicCatalogueFactory(context); + MusicCatalogueFactory factory = new MusicCatalogueFactory(context); // If this is a lookup, look up the album details - var values = _parser.GetValues(CommandLineOptionType.Lookup); + var values = parser.GetValues(CommandLineOptionType.Lookup); if (values != null) { - await LookupAlbum(values[0], values[1]); + await new AlbumLookup(logger, factory, settings!).LookupAlbum(values[0], values[1]); } - } - - /// - /// Lookup an album given the artist name and album title - /// - /// - /// - private static async Task LookupAlbum(string artistName, string albumTitle) - { - // Get the API key and the URLs for the album and track lookup endpoints - var key = _settings!.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; - var albumsEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; - var tracksEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; - - // Convert the URL into a URI instance that will expose the host name - this is needed - // to set up the client headers - var uri = new Uri(albumsEndpoint); - // Configure an HTTP client - var client = MusicHttpClient.Instance; - client.AddHeader("X-RapidAPI-Key", key); - client.AddHeader("X-RapidAPI-Host", uri.Host); - - // Configure the APIs - var albumsApi = new TheAudioDBAlbumsApi(_logger, client, albumsEndpoint); - var tracksApi = new TheAudioDBTracksApi(_logger, client, tracksEndpoint); - var lookupManager = new AlbumLookupManager(_logger, albumsApi, tracksApi, _factory); - - // Lookup the album and its tracks - var album = await lookupManager.LookupAlbum(artistName, albumTitle); - if (album != null) - { - // Dump the album details - Console.WriteLine($"Title: {album.Title}"); - Console.WriteLine($"Artist: {StringCleaner.Clean(artistName)}"); - Console.WriteLine($"Released: {album.Released}"); - Console.WriteLine($"Genre: {album.Genre}"); - Console.WriteLine($"Cover: {album.CoverUrl}"); - Console.WriteLine(); - - // Dump the track list - if ((album.Tracks != null) && (album.Tracks.Count > 0)) - { - foreach (var track in album.Tracks) - { - Console.WriteLine($"{track.Number} : {track.Title}, {track.FormattedDuration()}"); - } - Console.WriteLine(); - } - else - { - Console.WriteLine("No tracks found"); - } - } - else + // If this is an import, import data from the specified CSV file + values = parser.GetValues(CommandLineOptionType.Import); + if (values != null) { - Console.WriteLine("Album details not found"); + new DataImport(logger, factory).Import(values[0]); } } + } } \ No newline at end of file diff --git a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json index 784481f..4df230f 100644 --- a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json +++ b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "MusicCatalogue.LookupTool": { "commandName": "Project", - "commandLineArgs": "--lookup \"Dire Straits\" \"Brothers In Arms\"" + "commandLineArgs": "--import \"C:\\Temp\\2023-10-06.csv\"" } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Tests/CommandLineParserTest.cs b/src/MusicCatalogue.Tests/CommandLineParserTest.cs index d8bc26d..186feec 100644 --- a/src/MusicCatalogue.Tests/CommandLineParserTest.cs +++ b/src/MusicCatalogue.Tests/CommandLineParserTest.cs @@ -15,7 +15,8 @@ public class CommandLineParserTest public void TestInitialise() { _parser = new CommandLineParser(); - _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + _parser.Add(CommandLineOptionType.Lookup, true, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + _parser.Add(CommandLineOptionType.Import, true, "--import", "-i", "Import data from a CSV format file", 1, 1); } [TestMethod] @@ -88,21 +89,29 @@ public void MalformedCommandLineFailsTest() [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionTypeFailsTest() { - _parser!.Add(CommandLineOptionType.Lookup, "--other-lookup", "-ol", "Duplicate option type", 2, 2); + _parser!.Add(CommandLineOptionType.Lookup, true, "--other-lookup", "-ol", "Duplicate option type", 2, 2); } [TestMethod] [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionNameFailsTest() { - _parser!.Add(CommandLineOptionType.Unknown, "--lookup", "-ol", "Duplicate option name", 2, 2); + _parser!.Add(CommandLineOptionType.Unknown, true, "--lookup", "-ol", "Duplicate option name", 2, 2); } [TestMethod] [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionShortNameFailsTest() { - _parser!.Add(CommandLineOptionType.Unknown, "--other-lookup", "-l", "Duplicate option shortname", 2, 2); + _parser!.Add(CommandLineOptionType.Unknown, true, "--other-lookup", "-l", "Duplicate option shortname", 2, 2); + } + + [TestMethod] + [ExpectedException(typeof(MultipleOperationsException))] + public void MultipleOperationsFailsTest() + { + string[] args = new string[] { "--lookup", "The Beatles", "Let It Be", "--import", "a_file.csv" }; + _parser!.Parse(args); } } } From d83c9587c4eb262b761a0ac5959728cfa6504960 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Sat, 7 Oct 2023 10:48:56 +0100 Subject: [PATCH 3/5] Added CSV and Excel export --- .../CommandLine/CommandLineOptionType.cs | 1 + .../DataExchange/FlattenedTrack.cs | 34 ++++++- .../Interfaces/IExporter.cs | 11 +++ .../{ICsvImporter.cs => IImporter.cs} | 2 +- .../Interfaces/IMusicCatalogueFactory.cs | 4 +- .../DataExchange/CsvExporter.cs | 54 +++++++++++ .../DataExchange/CsvImporter.cs | 9 +- .../DataExchange/DataExchangeBase.cs | 16 ++++ .../DataExchange/DataExportBase.cs | 89 +++++++++++++++++++ .../DataExchange/XlsxExporter.cs | 75 ++++++++++++++++ .../Database/AlbumManager.cs | 2 +- .../Factory/MusicCatalogueFactory.cs | 16 +++- .../MusicCatalogue.Logic.csproj | 1 + .../Logic/DataExport.cs | 62 +++++++++++++ .../Logic/DataImport.cs | 4 +- src/MusicCatalogue.LookupTool/Program.cs | 8 ++ .../Properties/launchSettings.json | 2 +- .../appsettings.json | 2 +- 18 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 src/MusicCatalogue.Entities/Interfaces/IExporter.cs rename src/MusicCatalogue.Entities/Interfaces/{ICsvImporter.cs => IImporter.cs} (86%) create mode 100644 src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs create mode 100644 src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs create mode 100644 src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs create mode 100644 src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs create mode 100644 src/MusicCatalogue.LookupTool/Logic/DataExport.cs diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs index 4c0442e..e9de84d 100644 --- a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs @@ -5,5 +5,6 @@ public enum CommandLineOptionType Unknown, Lookup, Import, + Export, } } diff --git a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs index 30c49ca..1c489f3 100644 --- a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs +++ b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs @@ -1,5 +1,6 @@ using MusicCatalogue.Entities.Database; using System.Diagnostics.CodeAnalysis; +using System.Text; namespace MusicCatalogue.Entities.DataExchange { @@ -19,10 +20,10 @@ public class FlattenedTrack : TrackBase public string ArtistName{ get; set; } = ""; public string AlbumTitle { get; set; } = ""; - public string Genre { get; set; } = ""; + public string? Genre { get; set; } = ""; public int? Released { get; set; } public string? CoverUrl { get; set; } = ""; - public int TrackNumber { get; set; } + public int? TrackNumber { get; set; } public string Title { get; set; } = ""; /// @@ -31,8 +32,16 @@ public class FlattenedTrack : TrackBase /// public string ToCsv() { - var representation = $"\"{ArtistName}\",\"{AlbumTitle}\",\"{Genre}\",\"{Released}\",\"{CoverUrl}\",\"{Title}\",\"{FormattedDuration()}\""; - return representation; + StringBuilder builder = new StringBuilder(); + AppendField(builder, ArtistName); + AppendField(builder, AlbumTitle); + AppendField(builder, Genre); + AppendField(builder, Released); + AppendField(builder, CoverUrl); + AppendField(builder, TrackNumber); + AppendField(builder, Title); + AppendField(builder, FormattedDuration()); + return builder.ToString(); } /// @@ -66,5 +75,22 @@ public static FlattenedTrack FromCsv(string record) Duration = durationMs }; } + + /// + /// Append a value to a string builder holding a representation of a flattened track in CSV format + /// + /// + /// + private static void AppendField(StringBuilder builder, object? value) + { + if (builder.Length > 0) + { + builder.Append(','); + } + + builder.Append('"'); + builder.Append(value?.ToString() ?? ""); + builder.Append('"'); + } } } diff --git a/src/MusicCatalogue.Entities/Interfaces/IExporter.cs b/src/MusicCatalogue.Entities/Interfaces/IExporter.cs new file mode 100644 index 0000000..cdb740c --- /dev/null +++ b/src/MusicCatalogue.Entities/Interfaces/IExporter.cs @@ -0,0 +1,11 @@ +using MusicCatalogue.Entities.DataExchange; + +namespace MusicCatalogue.Entities.Interfaces +{ + public interface IExporter + { + event EventHandler? TrackExport; + + Task Export(string file); + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs b/src/MusicCatalogue.Entities/Interfaces/IImporter.cs similarity index 86% rename from src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs rename to src/MusicCatalogue.Entities/Interfaces/IImporter.cs index e43a5d0..2c2cff1 100644 --- a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IImporter.cs @@ -2,7 +2,7 @@ namespace MusicCatalogue.Entities.Interfaces { - public interface ICsvImporter + public interface IImporter { event EventHandler? TrackImport; Task Import(string file); diff --git a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs index 2ac4b72..4599a78 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs @@ -6,6 +6,8 @@ public interface IMusicCatalogueFactory IArtistManager Artists { get; } ITrackManager Tracks { get; } IUserManager Users { get; } - ICsvImporter Importer { get; } + IImporter Importer { get; } + IExporter CsvExporter { get; } + IExporter XlsxExporter { get; } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs new file mode 100644 index 0000000..d859b6e --- /dev/null +++ b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs @@ -0,0 +1,54 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace MusicCatalogue.Logic.DataExchange +{ + [ExcludeFromCodeCoverage] + public class CsvExporter : DataExportBase, IExporter + { + private StreamWriter? _writer = null; + +#pragma warning disable CS8618 + internal CsvExporter(IMusicCatalogueFactory factory) : base(factory) + { + } +#pragma warning restore CS8618 + + /// + /// Export the collection to a CSV file + /// + /// + /// + public async Task Export(string file) + { + // Open the CSV file + using (_writer = new(file, false, Encoding.UTF8)) + { + // Iterate over the collection, calling the row addition methods + await IterateOverCollection(); + } + } + + /// + /// Add the headers to the CSV file + /// + /// + protected override void AddHeaders(IEnumerable headers) + { + var csvHeaders = string.Join(",", headers); + _writer!.WriteLine(csvHeaders); + } + + /// + /// Add a track to the CSV file + /// + /// + /// + protected override void AddTrack(FlattenedTrack track, int _) + { + _writer!.WriteLine(track.ToCsv()); + } + } +} diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs index 8c770d8..6191905 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs @@ -1,7 +1,6 @@ using MusicCatalogue.Entities.DataExchange; using MusicCatalogue.Entities.Exceptions; using MusicCatalogue.Entities.Interfaces; -using MusicCatalogue.Logic.Factory; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.RegularExpressions; @@ -9,16 +8,13 @@ namespace MusicCatalogue.Logic.DataExchange { [ExcludeFromCodeCoverage] - public partial class CsvImporter : ICsvImporter + public partial class CsvImporter : DataExchangeBase, IImporter { - private readonly MusicCatalogueFactory _factory; - public event EventHandler? TrackImport; #pragma warning disable CS8618 - internal CsvImporter(MusicCatalogueFactory factory) + internal CsvImporter(IMusicCatalogueFactory factory) : base(factory) { - _factory = factory; } #pragma warning restore CS8618 @@ -74,6 +70,5 @@ public async Task Import(string file) } } } - } } diff --git a/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs b/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs new file mode 100644 index 0000000..4843180 --- /dev/null +++ b/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs @@ -0,0 +1,16 @@ +using MusicCatalogue.Entities.Interfaces; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Logic.DataExchange +{ + [ExcludeFromCodeCoverage] + public abstract class DataExchangeBase + { + protected readonly IMusicCatalogueFactory _factory; + + protected DataExchangeBase(IMusicCatalogueFactory factory) + { + _factory = factory; + } + } +} diff --git a/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs new file mode 100644 index 0000000..6daf8ed --- /dev/null +++ b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs @@ -0,0 +1,89 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Logic.DataExchange +{ + [ExcludeFromCodeCoverage] + public abstract class DataExportBase : DataExchangeBase + { + private readonly string[] ColumnHeaders = + { + "Artist", + "Album", + "Genre", + "Released", + "Cover Url", + "Track Number", + "Track", + "Duration", + }; + + public event EventHandler? TrackExport; + + protected DataExportBase(IMusicCatalogueFactory factory) : base(factory) + { + + } + + /// + /// Method to add headers to the output + /// + /// + protected abstract void AddHeaders(IEnumerable headers); + + /// + /// Method to add a new flattened track to the output + /// + /// + /// + protected abstract void AddTrack(FlattenedTrack track, int recordNumber); + + /// + /// Iterate over the collection calling the methods supplied by the child class to add + /// headers and to add each track to the output + /// + protected async Task IterateOverCollection() + { + // Call the method, supplied by the child class, to add the headers to the output + AddHeaders(ColumnHeaders); + + // Initialise the record count + int count = 0; + + // Retrieve a list of artists and their albums then iterate over the artists + // and albums + var artists = await _factory.Artists.ListAsync(x => true); + foreach (var artist in artists.OrderBy(x => x.Name)) + { + foreach (var album in artist.Albums.OrderBy(x => x.Title)) + { + // Retrieve the track list for this album and iterate over the tracks + var tracks = await _factory.Tracks.ListAsync(x => x.AlbumId == album.Id); + foreach (var track in tracks.OrderBy(x => x.Number)) + { + // Construct a flattened track + var flattened = new FlattenedTrack + { + ArtistName = artist.Name, + AlbumTitle = album.Title, + Genre = album.Genre, + Released = album.Released, + CoverUrl = album.CoverUrl, + TrackNumber = track.Number, + Title = track.Title, + Duration = track.Duration + }; + + // Call the method to add this track to the file + count++; + AddTrack(flattened, count); + + // Raise the track exported event + TrackExport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count, Track = flattened }); + } + } + } + } + } +} diff --git a/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs new file mode 100644 index 0000000..3dbe86f --- /dev/null +++ b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs @@ -0,0 +1,75 @@ +using ClosedXML.Excel; +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Logic.DataExchange +{ + [ExcludeFromCodeCoverage] + public class XlsxExporter : DataExportBase, IExporter + { + private const string WorksheetName = "Music"; + + private IXLWorksheet? _worksheet = null; + +#pragma warning disable CS8618 + internal XlsxExporter(IMusicCatalogueFactory factory) : base(factory) + { + } +#pragma warning restore CS8618 + + /// + /// Export the collection to a CSV file + /// + /// + /// + public async Task Export(string file) + { + // Create a new Excel Workbook + using (var workbook = new XLWorkbook()) + { + // Add a worksheet to contain the data + _worksheet = workbook.Worksheets.Add(WorksheetName); + + // Iterate over the collection, calling the row addition methods. This builds the spreadsheet + // in memory + await IterateOverCollection(); + + // Save the workbook to the specified file + workbook.SaveAs(file); + } + } + + /// + /// Add the headers to the CSV file + /// + /// + protected override void AddHeaders(IEnumerable headers) + { + var columnNumber = 1; + foreach (var header in headers) + { + _worksheet!.Cell(1, columnNumber).Value = header; + columnNumber++; + } + } + + /// + /// Add a track to the CSV file + /// + /// + /// + protected override void AddTrack(FlattenedTrack track, int recordCount) + { + var row = recordCount + 1; + _worksheet!.Cell(row, 1).Value = track.ArtistName ?? ""; + _worksheet!.Cell(row, 2).Value = track.AlbumTitle ?? ""; + _worksheet!.Cell(row, 3).Value = track.Genre ?? ""; + _worksheet!.Cell(row, 4).Value = track.Released?.ToString() ?? ""; + _worksheet!.Cell(row, 5).Value = track.CoverUrl ?? ""; + _worksheet!.Cell(row, 6).Value = track.TrackNumber?.ToString() ?? ""; + _worksheet!.Cell(row, 7).Value = track.Title ?? ""; + _worksheet!.Cell(row, 8).Value = track.FormattedDuration() ?? ""; + } + } +} diff --git a/src/MusicCatalogue.Logic/Database/AlbumManager.cs b/src/MusicCatalogue.Logic/Database/AlbumManager.cs index 72497fc..0c41271 100644 --- a/src/MusicCatalogue.Logic/Database/AlbumManager.cs +++ b/src/MusicCatalogue.Logic/Database/AlbumManager.cs @@ -33,8 +33,8 @@ public async Task GetAsync(Expression> predicate) /// public async Task> ListAsync(Expression> predicate) => await _context.Albums - .Include(x => x.Tracks) .Where(predicate) + .Include(x => x.Tracks) .ToListAsync(); /// diff --git a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs index d44eea8..453d44c 100644 --- a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs @@ -12,7 +12,9 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory private readonly Lazy _albums; private readonly Lazy _tracks; private readonly Lazy _users; - private readonly Lazy _importer; + private readonly Lazy _importer; + private readonly Lazy _csvExporter; + private readonly Lazy _xlsxExporter; public IArtistManager Artists { get { return _artists.Value; } } public IAlbumManager Albums { get { return _albums.Value; } } @@ -20,7 +22,13 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory public IUserManager Users { get { return _users.Value; } } [ExcludeFromCodeCoverage] - public ICsvImporter Importer { get { return _importer.Value; } } + public IImporter Importer { get { return _importer.Value; } } + + [ExcludeFromCodeCoverage] + public IExporter CsvExporter { get { return _csvExporter.Value; } } + + [ExcludeFromCodeCoverage] + public IExporter XlsxExporter { get { return _xlsxExporter.Value; } } public MusicCatalogueFactory(MusicCatalogueDbContext context) { @@ -28,7 +36,9 @@ public MusicCatalogueFactory(MusicCatalogueDbContext context) _albums = new Lazy(() => new AlbumManager(context)); _tracks = new Lazy(() => new TrackManager(context)); _users = new Lazy(() => new UserManager(context)); - _importer =new Lazy(() => new CsvImporter(this)); + _importer = new Lazy(() => new CsvImporter(this)); + _csvExporter = new Lazy(() => new CsvExporter(this)); + _xlsxExporter = new Lazy(() => new XlsxExporter(this)); } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj index 09c8d7f..6f0fd82 100644 --- a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj +++ b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj @@ -21,6 +21,7 @@ + diff --git a/src/MusicCatalogue.LookupTool/Logic/DataExport.cs b/src/MusicCatalogue.LookupTool/Logic/DataExport.cs new file mode 100644 index 0000000..979ec56 --- /dev/null +++ b/src/MusicCatalogue.LookupTool/Logic/DataExport.cs @@ -0,0 +1,62 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Entities.Logging; + +namespace MusicCatalogue.LookupTool.Logic +{ + internal class DataExport + { + private readonly IMusicLogger _logger; + private readonly IMusicCatalogueFactory _factory; + + public DataExport(IMusicLogger logger, IMusicCatalogueFactory factory) + { + _logger = logger; + _factory = factory; + } + + /// + /// Export the collection to the specified file + /// + /// + public void Export(string file) + { + _logger.LogMessage(Severity.Info, $"Exporting {file} ..."); + + // TODO: Use the file extension to decide which exporter to use + var extension = Path.GetExtension(file).ToLower(); + IExporter? exporter = extension == ".xlsx" ? _factory.XlsxExporter : _factory.CsvExporter; + + try + { + // Register a handler for the "track imported" event and import the file + exporter!.TrackExport += OnTrackExported; + Task.Run(() => exporter.Export(file)).Wait(); + } + catch (Exception ex) + { + Console.WriteLine($"Export error: {ex.Message}"); + _logger.LogMessage(Severity.Info, $"Export error: {ex.Message}"); + _logger.LogException(ex); + } + finally + { + // Un-register the event handler + exporter!.TrackExport -= OnTrackExported; + } + } + + /// + /// Handler called when a track is imported + /// + /// + /// + public void OnTrackExported(object? sender, TrackDataExchangeEventArgs e) + { + if (e.Track != null) + { + Console.WriteLine($"Exported {e.Track.ArtistName}, {e.Track.AlbumTitle} - {e.Track.TrackNumber} : {e.Track.Title}"); + } + } + } +} diff --git a/src/MusicCatalogue.LookupTool/Logic/DataImport.cs b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs index b000318..5d231b5 100644 --- a/src/MusicCatalogue.LookupTool/Logic/DataImport.cs +++ b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs @@ -27,7 +27,7 @@ public void Import(string file) { // Register a handler for the "track imported" event and import the file _factory.Importer.TrackImport += OnTrackImported; - _factory.Importer.Import(file); + Task.Run(() => _factory.Importer.Import(file)).Wait(); } catch (Exception ex) { @@ -51,7 +51,7 @@ public void OnTrackImported(object? sender, TrackDataExchangeEventArgs e) { if (e.Track != null) { - Console.WriteLine($"Imported {e.Track.ArtistName} {e.Track.AlbumTitle} {e.Track.TrackNumber} {e.Track.Title}"); + Console.WriteLine($"Imported {e.Track.ArtistName}, {e.Track.AlbumTitle} - {e.Track.TrackNumber} : {e.Track.Title}"); } } } diff --git a/src/MusicCatalogue.LookupTool/Program.cs b/src/MusicCatalogue.LookupTool/Program.cs index 353d5dc..d3cbdd9 100644 --- a/src/MusicCatalogue.LookupTool/Program.cs +++ b/src/MusicCatalogue.LookupTool/Program.cs @@ -25,6 +25,7 @@ public static async Task Main(string[] args) CommandLineParser parser = new(); parser.Add(CommandLineOptionType.Lookup, true, "--lookup", "-l", "Lookup an album and display its details", 2, 2); parser.Add(CommandLineOptionType.Import, true, "--import", "-i", "Import data from a CSV format file", 1, 1); + parser.Add(CommandLineOptionType.Export, true, "--export", "-e", "Export the collection to a CSV file or Excel Workbook", 1, 1); parser.Parse(args); // Read the application settings @@ -61,6 +62,13 @@ public static async Task Main(string[] args) { new DataImport(logger, factory).Import(values[0]); } + + // If this is an export, export the collection to the specified file + values = parser.GetValues(CommandLineOptionType.Export); + if (values != null) + { + new DataExport(logger, factory).Export(values[0]); + } } } diff --git a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json index 4df230f..0787185 100644 --- a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json +++ b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "MusicCatalogue.LookupTool": { "commandName": "Project", - "commandLineArgs": "--import \"C:\\Temp\\2023-10-06.csv\"" + "commandLineArgs": "--export \"C:\\Temp\\Fred.xlsx\"" } } } \ No newline at end of file diff --git a/src/MusicCatalogue.LookupTool/appsettings.json b/src/MusicCatalogue.LookupTool/appsettings.json index b87467e..08908e7 100644 --- a/src/MusicCatalogue.LookupTool/appsettings.json +++ b/src/MusicCatalogue.LookupTool/appsettings.json @@ -2,7 +2,7 @@ "ApplicationSettings": { "Secret": "e2b6e7fe16ef469d9862d43eb76d00e2802ab769b85848048cc9387743ca2cc38c0f4fd8a0de46798f347bedf676bc31", "TokenLifespanMinutes": 1440, - "LogFile": "C:\\MyApps\\MusicCatalogue.log", + "LogFile": "C:\\MyApps\\MusicCatalogue.LookupTool.log", "MinimumLogLevel": "Info", "ApiEndpoints": [ { From 24d9da54c4a258a6108817c42495a01ad73790f0 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Sun, 8 Oct 2023 09:13:22 +0100 Subject: [PATCH 4/5] Added data exchange test --- .../DataExchange/CsvExporter.cs | 1 - .../DataExchange/DataExchangeBase.cs | 1 - .../DataExchange/DataExportBase.cs | 1 - .../DataExchange/XlsxExporter.cs | 1 - src/MusicCatalogue.Tests/DataExchangeTest.cs | 60 +++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/MusicCatalogue.Tests/DataExchangeTest.cs diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs index d859b6e..e392090 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs @@ -5,7 +5,6 @@ namespace MusicCatalogue.Logic.DataExchange { - [ExcludeFromCodeCoverage] public class CsvExporter : DataExportBase, IExporter { private StreamWriter? _writer = null; diff --git a/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs b/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs index 4843180..7fc948f 100644 --- a/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs +++ b/src/MusicCatalogue.Logic/DataExchange/DataExchangeBase.cs @@ -3,7 +3,6 @@ namespace MusicCatalogue.Logic.DataExchange { - [ExcludeFromCodeCoverage] public abstract class DataExchangeBase { protected readonly IMusicCatalogueFactory _factory; diff --git a/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs index 6daf8ed..4a225e0 100644 --- a/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs +++ b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs @@ -4,7 +4,6 @@ namespace MusicCatalogue.Logic.DataExchange { - [ExcludeFromCodeCoverage] public abstract class DataExportBase : DataExchangeBase { private readonly string[] ColumnHeaders = diff --git a/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs index 3dbe86f..7957c7b 100644 --- a/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs @@ -5,7 +5,6 @@ namespace MusicCatalogue.Logic.DataExchange { - [ExcludeFromCodeCoverage] public class XlsxExporter : DataExportBase, IExporter { private const string WorksheetName = "Music"; diff --git a/src/MusicCatalogue.Tests/DataExchangeTest.cs b/src/MusicCatalogue.Tests/DataExchangeTest.cs new file mode 100644 index 0000000..b7d0b2e --- /dev/null +++ b/src/MusicCatalogue.Tests/DataExchangeTest.cs @@ -0,0 +1,60 @@ +using MusicCatalogue.Data; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Factory; + +namespace MusicCatalogue.Tests +{ + [TestClass] + public class DataExchangeTest + { + private const string ArtistName = "Nat King Cole\r\n"; + private const string AlbumName = "After Midnight"; + private const string Genre = "Jazz"; + private const int Released = 1957; + private const string CoverUrl = "https://some.server/after-mightnight.jpg"; + private const int TrackNumber = 1; + private const string TrackName = "Just You Just Me"; + private const int Duration = 180000; + + private IMusicCatalogueFactory? _factory = null; + + [TestInitialize] + public void Initialise() + { + // Create an instance of the factory + MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); + _factory = new MusicCatalogueFactory(context); + + // Add an artist, an album and one track + var artist = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result; + var album = Task.Run(() => _factory.Albums.AddAsync(artist.Id, AlbumName, Released, Genre, CoverUrl)).Result; + Task.Run(() => _factory.Tracks.AddAsync(album.Id, TrackName, TrackNumber, Duration)).Wait(); + } + + [TestMethod] + public void ExportCsvTest() + { + var filepath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + _factory!.CsvExporter.Export(filepath); + + var info = new FileInfo(filepath); + Assert.AreEqual(info.FullName, filepath); + Assert.IsTrue(info.Length > 0); + + File.Delete(filepath); + } + + [TestMethod] + public void ExportXlsxTest() + { + var filepath = Path.ChangeExtension(Path.GetTempFileName(), "xlsx"); + _factory!.XlsxExporter.Export(filepath); + + var info = new FileInfo(filepath); + Assert.AreEqual(info.FullName, filepath); + Assert.IsTrue(info.Length > 0); + + File.Delete(filepath); + } + } +} \ No newline at end of file From cf41095c3c0e069264643d7d927c1db63641900f Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Sun, 8 Oct 2023 09:48:34 +0100 Subject: [PATCH 5/5] Added CSV import tests --- .../Database/AlbumManager.cs | 9 ++-- .../Database/ArtistManager.cs | 5 +- .../Database/StringCleaner.cs | 36 ++++++++++++-- .../Database/TrackManager.cs | 6 ++- src/MusicCatalogue.Tests/DataExchangeTest.cs | 49 +++++++++++++++++-- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/MusicCatalogue.Logic/Database/AlbumManager.cs b/src/MusicCatalogue.Logic/Database/AlbumManager.cs index 0c41271..d93ca67 100644 --- a/src/MusicCatalogue.Logic/Database/AlbumManager.cs +++ b/src/MusicCatalogue.Logic/Database/AlbumManager.cs @@ -48,17 +48,18 @@ public async Task> ListAsync(Expression> predicate /// public async Task AddAsync(int artistId, string title, int? released, string? genre, string? coverUrl) { - var album = await GetAsync(a => (a.ArtistId == artistId) && (a.Title == title)); + var clean = StringCleaner.Clean(title)!; + var album = await GetAsync(a => (a.ArtistId == artistId) && (a.Title == clean)); if (album == null) { album = new Album { ArtistId = artistId, - Title = StringCleaner.Clean(title), + Title = clean, Released = released, - Genre = genre, - CoverUrl = coverUrl + Genre = StringCleaner.RemoveInvalidCharacters(genre), + CoverUrl = StringCleaner.RemoveInvalidCharacters(coverUrl) }; await _context.Albums.AddAsync(album); await _context.SaveChangesAsync(); diff --git a/src/MusicCatalogue.Logic/Database/ArtistManager.cs b/src/MusicCatalogue.Logic/Database/ArtistManager.cs index 0dbcbd8..80438d0 100644 --- a/src/MusicCatalogue.Logic/Database/ArtistManager.cs +++ b/src/MusicCatalogue.Logic/Database/ArtistManager.cs @@ -44,13 +44,14 @@ public async Task> ListAsync(Expression> predica /// public async Task AddAsync(string name) { - var artist = await GetAsync(a => a.Name == name); + var clean = StringCleaner.Clean(name)!; + var artist = await GetAsync(a => a.Name == clean); if (artist == null) { artist = new Artist { - Name = StringCleaner.Clean(name) + Name = clean }; await _context.Artists.AddAsync(artist); await _context.SaveChangesAsync(); diff --git a/src/MusicCatalogue.Logic/Database/StringCleaner.cs b/src/MusicCatalogue.Logic/Database/StringCleaner.cs index 5ff1319..8402fc4 100644 --- a/src/MusicCatalogue.Logic/Database/StringCleaner.cs +++ b/src/MusicCatalogue.Logic/Database/StringCleaner.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Linq; namespace MusicCatalogue.Logic.Database { @@ -12,11 +13,38 @@ public static class StringCleaner /// /// /// - public static string Clean(string s) + public static string? Clean(string? s) { - // Conversion to lowercase, first, ensures that the result truly is title case. Otherwise, - // strings such as "The BEATLES" would remain unchanged, where we really want "The Beatles" - var clean = _textInfo.ToTitleCase(s.ToLower()); + var clean = s; + + // Check the string isn't null or empty + if (!string.IsNullOrEmpty(s)) + { + // Remove invalid characters, that can cause an exception in the title case conversion, and + // convert to lowercase to ensure that the result truly is title case. Otherwise, strings + // such as "The BEATLES" would remain unchanged, where we really want "The Beatles". + clean = _textInfo.ToTitleCase(RemoveInvalidCharacters(s)!.ToLower()); + } + + return clean; + } + + /// + /// Remove invalid characters from the string + /// + /// + /// + public static string? RemoveInvalidCharacters(string? s) + { + var clean = s; + + // Check the string isn't null or empty + if (!string.IsNullOrEmpty(s)) + { + // Remove commas that are not permitted (foul up the CSV export) and CR/LF + clean = s.Replace(",", "").Replace("\r", "").Replace("\n", ""); + } + return clean; } } diff --git a/src/MusicCatalogue.Logic/Database/TrackManager.cs b/src/MusicCatalogue.Logic/Database/TrackManager.cs index ad2a7dc..a7a3073 100644 --- a/src/MusicCatalogue.Logic/Database/TrackManager.cs +++ b/src/MusicCatalogue.Logic/Database/TrackManager.cs @@ -3,6 +3,7 @@ using MusicCatalogue.Entities.Interfaces; using MusicCatalogue.Entities.Database; using System.Linq.Expressions; +using DocumentFormat.OpenXml.Wordprocessing; namespace MusicCatalogue.Logic.Database { @@ -46,14 +47,15 @@ public async Task> ListAsync(Expression> predicate /// public async Task AddAsync(int albumId, string title, int? number, int? duration) { - var track = await GetAsync(a => (a.AlbumId == albumId) && (a.Title == title)); + var clean = StringCleaner.Clean(title)!; + var track = await GetAsync(a => (a.AlbumId == albumId) && (a.Title == clean)); if (track == null) { track = new Track { AlbumId = albumId, - Title = StringCleaner.Clean(title), + Title = clean, Number = number, Duration = duration }; diff --git a/src/MusicCatalogue.Tests/DataExchangeTest.cs b/src/MusicCatalogue.Tests/DataExchangeTest.cs index b7d0b2e..fd1908a 100644 --- a/src/MusicCatalogue.Tests/DataExchangeTest.cs +++ b/src/MusicCatalogue.Tests/DataExchangeTest.cs @@ -1,5 +1,6 @@ using MusicCatalogue.Data; using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Database; using MusicCatalogue.Logic.Factory; namespace MusicCatalogue.Tests @@ -7,7 +8,7 @@ namespace MusicCatalogue.Tests [TestClass] public class DataExchangeTest { - private const string ArtistName = "Nat King Cole\r\n"; + private const string ArtistName = "Nat, King Cole\r\n"; private const string AlbumName = "After Midnight"; private const string Genre = "Jazz"; private const int Released = 1957; @@ -17,6 +18,7 @@ public class DataExchangeTest private const int Duration = 180000; private IMusicCatalogueFactory? _factory = null; + private string _cleanArtistName = StringCleaner.Clean(ArtistName)!; [TestInitialize] public void Initialise() @@ -35,7 +37,7 @@ public void Initialise() public void ExportCsvTest() { var filepath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); - _factory!.CsvExporter.Export(filepath); + Task.Run(() => _factory!.CsvExporter.Export(filepath)).Wait(); var info = new FileInfo(filepath); Assert.AreEqual(info.FullName, filepath); @@ -48,7 +50,7 @@ public void ExportCsvTest() public void ExportXlsxTest() { var filepath = Path.ChangeExtension(Path.GetTempFileName(), "xlsx"); - _factory!.XlsxExporter.Export(filepath); + Task.Run(() => _factory!.XlsxExporter.Export(filepath)).Wait(); var info = new FileInfo(filepath); Assert.AreEqual(info.FullName, filepath); @@ -56,5 +58,46 @@ public void ExportXlsxTest() File.Delete(filepath); } + + [TestMethod] + public void ImportCsvTest() + { + // Export the data + var filepath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + Task.Run(() => _factory!.CsvExporter.Export(filepath)).Wait(); + + // Create a new instance of the factory with a new in-memory database + MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); + var factory = new MusicCatalogueFactory(context); + + // Confirm the data's not there + var artists = Task.Run(() => factory.Artists.ListAsync(x => true)).Result; + var albums = Task.Run(() => factory.Albums.ListAsync(x => true)).Result; + var tracks = Task.Run(() => factory.Tracks.ListAsync(x => true)).Result; + + Assert.AreEqual(0, artists.Count); + Assert.AreEqual(0, albums.Count); + Assert.AreEqual(0, tracks.Count); + + // Import the data + Task.Run(() => factory.Importer.Import(filepath)).Wait(); + artists = Task.Run(() => factory.Artists.ListAsync(x => true)).Result; + albums = Task.Run(() => factory.Albums.ListAsync(x => true)).Result; + tracks = Task.Run(() => factory.Tracks.ListAsync(x => true)).Result; + + Assert.AreEqual(1, artists.Count); + Assert.AreEqual(_cleanArtistName, artists[0].Name); + + Assert.AreEqual(1, albums.Count); + Assert.AreEqual(AlbumName, albums[0].Title); + Assert.AreEqual(Genre, albums[0].Genre); + Assert.AreEqual(Released, albums[0].Released); + Assert.AreEqual(CoverUrl, albums[0].CoverUrl); + + Assert.AreEqual(1, tracks.Count); + Assert.AreEqual(TrackNumber, tracks[0].Number); + Assert.AreEqual(TrackName, tracks[0].Title); + Assert.AreEqual(Duration, tracks[0].Duration); + } } } \ No newline at end of file