diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..316173f --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Project-specific +*.thdb + +# User-specific files +*.DotSettings.user +.idea/ + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg + +# Others +~$* +*~ +CodeCoverage/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml diff --git a/Core/Common/FileTypes.cs b/Core/Common/FileTypes.cs new file mode 100644 index 0000000..ac6d419 --- /dev/null +++ b/Core/Common/FileTypes.cs @@ -0,0 +1,29 @@ +namespace TagHierarchyManager.Common; + +/// +/// A static class for grabbing file types, for both the core and the user interfaces. +/// +public static class FileTypes +{ + /// + /// Gets a list of all file types that are not tag hierarchy databases. + /// + public static List<(string FileExtension, string Name)> AllNonDatabaseFileTypes => + [ + MusicBeeTagHierarchyTemplate, + ]; + + /// + /// Gets the file type metadata for the MusicBee tag hierarchy template format (*.txt). + /// + /// Note that if .txt is used for other formats in the future, the user will have to be prompted on which format to + /// use. + public static (string FileExtension, string Name) MusicBeeTagHierarchyTemplate => + new(".txt", "MusicBee tag hierarchy template"); + + /// + /// Gets the file type metadata for the Tag Hierarchy Manager database file (*.thdb). + /// + public static (string FileExtension, string Name) TagDatabase => + new(".thdb", "Tag Hierarchy Manager hierarchy database"); +} \ No newline at end of file diff --git a/Core/Common/TagDatabaseSearchMode.cs b/Core/Common/TagDatabaseSearchMode.cs new file mode 100644 index 0000000..2c53ad5 --- /dev/null +++ b/Core/Common/TagDatabaseSearchMode.cs @@ -0,0 +1,30 @@ +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Common; + +/// +/// An enum representing the different search modes for the . +/// +public enum TagDatabaseSearchMode +{ + /// + /// Represents a "fuzzy" search, finding any tag whose name matches the relevant query in no matter where it is + /// matched. + /// + Fuzzy = 0, + + /// + /// Represents a search mode where the query must match at the beginning of the name. + /// + StartsWith = 1, + + /// + /// Represents a search mode where the query must match at the end of the name. + /// + EndsWith = 2, + + /// + /// Represents a search mode where it must match the exact query. + /// + ExactMatch = 3, +} \ No newline at end of file diff --git a/Core/ErrorMessages.cs b/Core/ErrorMessages.cs new file mode 100644 index 0000000..4030545 --- /dev/null +++ b/Core/ErrorMessages.cs @@ -0,0 +1,103 @@ +using TagHierarchyManager.Models; + +namespace TagHierarchyManager; + +/// +/// A static class storing error message strings for use when showing errors or throwing exceptions.

+/// Some classes can have their own error message classes for more specific messages, this is intended for error +/// messages that are used by multiple classes or do not fit into a specific class. +///
+public static class ErrorMessages +{ + /// + /// Indicates an attempt to load a file that is not a valid SQLite database. + /// + public const string DbFileNotValid = "This is not a valid SQLite database file."; + + /// + /// Indicates an attempt to use a that has not been fully initialised yet. + /// + public const string DbNotInitialised = "The tag hierarchy database has not been initialised."; + + /// + /// Indicates an attempt to load a database with a structure that doesn't match what the application expects. + /// + public const string DbNotValid = "This is not a valid database file for the Tag Hierarchy Manager application."; + + /// + /// Indicates no parameters being received in a method. + /// + public const string EmptyParameters = "No usable parameters received."; + + /// + /// Indicates an attempt to send an empty file path. + /// + public const string FilePathIsEmpty = "File path cannot be empty."; + + /// + /// Indicates an attempt to load a file that does not exist, when loading a . + /// + public const string FilePathNotFound = "No file exists at the specified path."; + + /// + /// Indicates an attempt to save to/load a file with an extension other than .thdb. + /// + public const string InvalidFileExtension = "File extension must be .thdb."; + + // Settings + /// + /// Indicates an attempt to delete a setting that is required. + /// + public const string SettingIsRequired = "This setting is required and cannot be deleted."; + + /// + /// Indicates that the specified tag cannot be deleted since it has child tags. + /// + public const string TagHasChildren = + "The tag requested has child tags and cannot currently be deleted. Delete its children first and try again."; + + /// + /// Indicates that the specified tag doesn't exist in the database. + /// + public const string TagNotFound = "The tag requested does not exist in the database."; + + /// + /// Generates a string indicating that the specified setting already exists in the . + /// + /// The specified setting key. + /// A string indicating the setting already exists. + public static string SettingKeyAlreadyExists(string key) + { + return $"Setting {key} already exists in the database."; + } + + /// + /// Generates a string indicating that the specified setting does not exist in the . + /// + /// The specified setting key. + /// A string indicating the setting does not exist. + public static string SettingKeyNotFound(string key) + { + return $"The setting key \"{key}\" was not found in the settings table."; + } + + /// + /// Method to construct a string for generic errors from SQLite. + /// + /// The SQLite error code. + /// A string indicating that the database couldn't be loaded due to an error in SQLite. + public static string SqliteGenericWithCode(int errorCode) + { + return $"Cannot load database due to an a SQLite error. Error code: {errorCode}"; + } + + /// + /// Method to construct a string for when attempting to save a new tag that already exists into the TagDatabase. + /// + /// The offending name of the tag. + /// A string indicating that the specified tag already exists in the TagDatabase. + public static string TagAlreadyExists(string tagName) + { + return $"Tag \"{tagName}\" already exists in the database."; + } +} \ No newline at end of file diff --git a/Core/Exporters/IExporter.cs b/Core/Exporters/IExporter.cs new file mode 100644 index 0000000..6792741 --- /dev/null +++ b/Core/Exporters/IExporter.cs @@ -0,0 +1,16 @@ +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Exporters; + +/// +/// A base interface for implementing exporter classes. +/// +public interface IExporter +{ + /// + /// Exports the tag hierarchy to a string format. + /// + /// The to export. + /// A string representing the exported output. + string ExportDatabase(TagDatabase db); +} \ No newline at end of file diff --git a/Core/Exporters/MusicBeeTagHierarchyExporter.cs b/Core/Exporters/MusicBeeTagHierarchyExporter.cs new file mode 100644 index 0000000..586bc98 --- /dev/null +++ b/Core/Exporters/MusicBeeTagHierarchyExporter.cs @@ -0,0 +1,59 @@ +using System.Text; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Exporters; + +/// +/// An IExporter class implementing the export of a MusicBee tag hierarchy template. +/// +public class MusicBeeTagHierarchyExporter : IExporter +{ + /// + /// Exports the to a string consisting of a tag hierarchy template that can be used in + /// MusicBee. + /// + /// The to export. + /// A string containing the tag hierarchy template. + public string ExportDatabase(TagDatabase db) + { + ArgumentNullException.ThrowIfNull(db); + + List topLevelTags = db.Tags.Where(tag => tag.IsTopLevel).OrderBy(tag => tag.Name).ToList(); + topLevelTags = topLevelTags.OrderBy(tag => tag.Name).ToList(); + StringBuilder currentString = new(); + foreach (Tag topLevelTag in topLevelTags) + ProcessRecursively(currentString, db, topLevelTag, string.Empty); + + return currentString.ToString(); + } + + private static void ProcessRecursively(StringBuilder currentString, TagDatabase db, Tag currentTag, + string indent = "") + { + List tagChildren = db.GetTagChildren(currentTag.Id).OrderBy(tag => tag.Name).ToList(); + if (tagChildren.Count > 0) + { + currentString.AppendLine(indent + currentTag.Name); + indent += " "; + + if (currentTag.TagBindings.Count > 0) ProcessTagBindings(currentTag, indent, currentString); + } + else + { + if (currentTag.TagBindings.Count == 0) + currentString.AppendLine(indent + currentTag.Name); + else + ProcessTagBindings(currentTag, indent, currentString); + } + + foreach (Tag childTag in tagChildren) + ProcessRecursively(currentString, db, childTag, indent); + } + + private static void ProcessTagBindings(Tag currentTag, string indent, StringBuilder builder) + { + foreach (string line in + currentTag.TagBindings.Select(tagBinding => indent + currentTag.Name + $"::{tagBinding}")) + builder.AppendLine(line); + } +} \ No newline at end of file diff --git a/Core/Importers/Importer.cs b/Core/Importers/Importer.cs new file mode 100644 index 0000000..7ba5d28 --- /dev/null +++ b/Core/Importers/Importer.cs @@ -0,0 +1,41 @@ +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Importers; + +/// +/// An abstract class implementing an importer, converting a file in a specific format to a Dictionary of s.
+/// The resulting Dictionary can then be used to fill a on creation. +///
+public abstract class Importer +{ + /// + /// Initializes a new instance of the class. + /// + protected Importer() + { + } + + /// + /// Gets or sets the name of the format, for use in file dialogs. + /// + // ReSharper disable once UnusedAutoPropertyAccessor.Global + public string FormatName { get; protected init; } = string.Empty; + + /// + /// Gets all the contained text of a given file, then sends that string to . + /// + /// The location of the file to be imported. + /// A representing the asynchronous operation, returning a Dictionary of objects. + public async Task> ImportFromFileAsync(string filePath) + { + string importedData = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + return await this.ProcessDataToDatabaseAsync(importedData).ConfigureAwait(false); + } + + /// + /// Abstract method for inherited classes to implement the actual importing logic. + /// + /// The string containing the data to be imported. + /// A representing the asynchronous operation. + protected abstract Task> ProcessDataToDatabaseAsync(string importedData); +} \ No newline at end of file diff --git a/Core/Importers/MusicBeeTagHierarchyImporter/ErrorMessages.cs b/Core/Importers/MusicBeeTagHierarchyImporter/ErrorMessages.cs new file mode 100644 index 0000000..0782726 --- /dev/null +++ b/Core/Importers/MusicBeeTagHierarchyImporter/ErrorMessages.cs @@ -0,0 +1,62 @@ +namespace TagHierarchyManager.Importers; + +public partial class MusicBeeTagHierarchyImporter +{ + /// + /// A class storing error messages for exceptions. + /// + public static class ErrorMessages + { + /// + /// Indicates that an excessive amount of indents was detected in the tag hierarchy template, with a + /// placeholder for the line number. + /// + public const string IndentIsExcessiveTemplate = + "Excessive indent was detected at line {0}."; + + /// + /// Indicates that an uneven amount of indents was detected in the tag hierarchy template, with a + /// placeholder for the line number. + /// + public const string IndentIsUnevenTemplate = + "Uneven indent was found at line {0}."; + + /// + /// Indicates an attempt to remove a parent tag from the parent stack resulting in an Exception. + /// + public const string TagHierarchyPopAttemptOutOfRange = + "Process exited abruptly due to an error with handling the indent. (current line's indent level was greater than the amount of tags in the parent stack)"; + + /// + /// Indicates that a space character was detected at the beginning of the tag hierarchy template. + /// + public const string TagHierarchyStartsWithSpace = + "Tag hierarchy starts with a space, which is not valid for a tag hierarchy template's structure."; + + /// + /// Indicates that a tab character was detected in tag hierarchy template. + /// + public const string TagHierarchyTabsDetected = + "Tab characters were detected, which is an invalid structure for MusicBee tag hierarchy templates."; + + /// + /// Constructs a string indicating an excessive amount of indents in the tag hierarchy template. + /// + /// The offending line number of the template. + /// A string stating that an excessive amount of indents was found at a particular given line. + public static string TagHierarchyIndentIsExcessive(int lineNumber) + { + return string.Format(IndentIsExcessiveTemplate, lineNumber); + } + + /// + /// Constructs a string indicating an uneven indent in the tag hierarchy template. + /// + /// The offending line number of the template. + /// A formatted string stating that an uneven indent was found at a particular given line. + public static string TagHierarchyIndentIsUneven(int lineNumber) + { + return string.Format(IndentIsUnevenTemplate, lineNumber); + } + } +} \ No newline at end of file diff --git a/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs b/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs new file mode 100644 index 0000000..7e6b5e8 --- /dev/null +++ b/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs @@ -0,0 +1,160 @@ +using TagHierarchyManager.Common; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Importers; +/// +/// Implements an importer for converting a MusicBee tag hierarchy template to a Dictionary of s. +/// +public partial class MusicBeeTagHierarchyImporter : Importer +{ + private const char CommentSymbol = ';'; + private const int IndentSize = 4; // MusicBee is strict about having an indent size of 4 spaces. + private const string TagBindingSeparator = "::"; + + /// + /// Initializes a new instance of the class. + /// + public MusicBeeTagHierarchyImporter() + { + this.FormatName = FileTypes.MusicBeeTagHierarchyTemplate.Name; + } + + /// + /// Converts a MusicBee tag hierarchy template into a Dictionary of s. + /// + /// The tag hierarchy data string. + /// A representing the asynchronous operation, returning a Dictionary of s. + protected override async Task> ProcessDataToDatabaseAsync(string importedData) + { + importedData = importedData.TrimEnd(); + + ValidateHierarchyData(importedData); + + Dictionary tagsToImport = new(); + + int previousIndentLevel = 0; + List parentStack = []; + int lineCounter = 1; + + string lastTagName = string.Empty; + using StringReader reader = new(importedData); + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (IsEmptyOrComment(line)) continue; + + TagHierarchyLine parsedLine = new(line, lineCounter); + UpdateParentStack(parentStack, parsedLine, previousIndentLevel, lastTagName); + + ImportTag(tagsToImport, parsedLine, parentStack); + + lastTagName = parsedLine.TagName; + previousIndentLevel = parsedLine.IndentLevel; + lineCounter++; + } + + return tagsToImport; + } + + private static void AddTagBindingIfMissing(ImportedTag currentTag, string tagBinding) + { + if (!string.IsNullOrEmpty(tagBinding)) + currentTag.TagBindings.Add(tagBinding); + } + + private static bool IsEmptyOrComment(string line) + { + return string.IsNullOrEmpty(line) || string.IsNullOrWhiteSpace(line) || line.StartsWith(CommentSymbol); + } + + private static void UpdateParentStack(List parentStack, TagHierarchyLine currentLine, int previousIndent, + string parentName) + { + if (currentLine.IndentLevel > previousIndent) + { + if (currentLine.IndentLevel - previousIndent > 1) + throw new ArgumentException( + ErrorMessages.TagHierarchyIndentIsExcessive(currentLine.LineNumber)); + + if (!string.IsNullOrEmpty(parentName)) parentStack.Add(parentName); + } + else if (currentLine.IndentLevel < previousIndent) + { + if (currentLine.IndentLevel <= parentStack.Count) + parentStack.RemoveRange(currentLine.IndentLevel, parentStack.Count - currentLine.IndentLevel); + else + throw new InvalidOperationException(ErrorMessages.TagHierarchyPopAttemptOutOfRange); + } + } + + private static void ValidateHierarchyData(string tagHierarchyData) + { + if (tagHierarchyData.Contains('\t')) throw new ArgumentException(ErrorMessages.TagHierarchyTabsDetected); + + if (tagHierarchyData.StartsWith(' ')) throw new ArgumentException(ErrorMessages.TagHierarchyStartsWithSpace); + } + + private static void ImportTag(Dictionary importDict, TagHierarchyLine line, List parentStack) + { + ImportedTag? existingTag = importDict.GetValueOrDefault(line.TagName); + if (existingTag is null) + { + ImportedTag newTag = new() + { + Name = line.TagName, + IsTopLevel = parentStack.Count <= 0, + }; + importDict[line.TagName] = newTag; + } + + ProcessParents(importDict[line.TagName], parentStack); + AddTagBindingIfMissing(importDict[line.TagName], line.Binding); + } + + + private static void ProcessParents(ImportedTag tag, List parentStack) + { + if (parentStack.Count == 0) + { + tag.IsTopLevel = true; + return; + } + + string parentName = parentStack[^1]; + if (tag.Name != parentName) + { + tag.Parents.Add(parentName); + } + } + + private struct TagHierarchyLine + { + public readonly string Binding = string.Empty; + public readonly int IndentLevel = 0; + public readonly int LineNumber = 0; + public readonly string TagName; + + public TagHierarchyLine(string line, int lineCounter) + { + this.LineNumber = lineCounter; + string trimmedLine = line.TrimStart(); + + int separatorIndex = trimmedLine.LastIndexOf(TagBindingSeparator, StringComparison.Ordinal); + if (separatorIndex != -1) + { + this.TagName = trimmedLine[..separatorIndex]; + this.Binding = trimmedLine[(separatorIndex + TagBindingSeparator.Length)..]; + } + else + { + this.TagName = trimmedLine; + } + + int indentRemainder = (line.Length - trimmedLine.Length) % IndentSize; + if (indentRemainder != 0) + throw new ArgumentException( + ErrorMessages.TagHierarchyIndentIsUneven(lineCounter)); + + this.IndentLevel = (line.Length - trimmedLine.Length) / IndentSize; + } + } +} \ No newline at end of file diff --git a/Core/Models/ImportedTag.cs b/Core/Models/ImportedTag.cs new file mode 100644 index 0000000..cc1f219 --- /dev/null +++ b/Core/Models/ImportedTag.cs @@ -0,0 +1,44 @@ +namespace TagHierarchyManager.Models; + +/// +/// This class represents a tag that has been imported from an external source. +/// This is intended for intermediate representation before being added to the TagDatabase. +/// +public class ImportedTag +{ + /// + /// Gets or sets a list of strings containing aliases/"also known as". Internally, this is saved as semicolons. + /// + // disable resharper warnings since it can be used with other formats that use aliases. + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + // ReSharper disable once CollectionNeverUpdated.Global + public HashSet Aliases { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the tag is top level. + /// + public required bool IsTopLevel { get; set; } + + /// + /// Gets or sets the user-facing name of the tag. + /// + public required string Name { get; init; } + + /// + /// Gets or sets a string for any plain text specified. This will not be shown on exports and is intended for internal + /// use. + /// + public string Notes { get; set; } = string.Empty; + + /// + /// Gets or sets a list of the tag entry's parent names, stored for saving new parents in a user interface. + /// + public HashSet Parents { get; set; } = []; + + /// + /// Gets or sets a list of strings, listing tag bindings associated with the current tag entry. + /// Internally it will be stored as a semicolons (e.g. genre; style)
+ /// A tag can have no bindings, if the user wants to use it as a category and not a tag in itself. + ///
+ public HashSet TagBindings { get; set; } = []; +} \ No newline at end of file diff --git a/Core/Models/Tag/Tag.ErrorMessages.cs b/Core/Models/Tag/Tag.ErrorMessages.cs new file mode 100644 index 0000000..c778a8a --- /dev/null +++ b/Core/Models/Tag/Tag.ErrorMessages.cs @@ -0,0 +1,32 @@ +namespace TagHierarchyManager.Models; + +public partial class Tag +{ + /// + /// Error messages associated with the Tag object. + /// + public static class ErrorMessages + { + /// + /// Constructs a string indicating an attempt to make a tag a parent of itself. + /// + /// The name of the offending Tag object. + /// + /// An error message string indicating that the tag has itself in Parents (id) or ParentNames (name). + /// + public static string MakingSelfParentAttempt(string name) + { + return $"Tag '{name}' has itself in Parents or ParentNames, which is invalid."; + } + + /// + /// Constructs a string indicating that a specified tag is an orphan. + /// + /// The name of the offending Tag object. + /// A string indicating that the tag has no parents specified and can't be top level. + public static string OrphanTagAttempt(string name) + { + return $"Tag '{name}' has no parents specified, so cannot be non-top-level."; + } + } +} \ No newline at end of file diff --git a/Core/Models/Tag/Tag.cs b/Core/Models/Tag/Tag.cs new file mode 100644 index 0000000..bf62d52 --- /dev/null +++ b/Core/Models/Tag/Tag.cs @@ -0,0 +1,78 @@ +namespace TagHierarchyManager.Models; + +/// +/// An object representing a tag entry in a tag hierarchy database. +/// +public partial class Tag +{ + /// + /// Gets or sets a list of strings containing aliases (or "also known as"), saved in the database as semicolons. + /// + public List Aliases { get; set; } = []; + + /// + /// Gets or sets he internal ID of a tag entry.

+ /// Can be zero when constructing a Tag to send to the database, but should be more than zero for tags retrieved + /// from the database. + ///
+ public int Id { get; set; } + + /// + /// Gets or sets a value indicating whether the tag is top level. + /// + public required bool IsTopLevel { get; set; } + + /// + /// Gets or sets the user-facing name of the tag. + /// + public required string Name { get; set; } + + /// + /// Gets or sets a string for any plain text associated with the tag to be used for notes. + /// This is mainly for internal use by the user. + /// + public string Notes { get; set; } = string.Empty; + + /// + /// Gets or sets a list of the tag entry's parent IDs for interaction with the database. + /// + public List ParentIds { get; set; } = []; + + /// + /// Gets or sets a list of the tag entry's parent names, stored for saving new parents in a user interface. + /// + public List Parents { get; set; } = []; + + /// + /// Gets or sets a list of strings, listing tag bindings associated with the current tag entry. + /// Internally it will be stored as a semicolons (e.g. genre; style)
+ /// A tag can have no bindings, if the user wants to use it as a category and not a tag in itself. + ///
+ public List TagBindings { get; set; } = []; + + /// + public override string ToString() + { + return this.Name + (this.TagBindings.Count > 0 ? $" ({string.Join("; ", this.TagBindings)})" : string.Empty); + } + + /// + /// Performs two validation checks: if the tag is not top level and the Parents and ParentNames properties are + /// empty (basically making the tag "orphaned"), and if an attempt is made to make a tag its own parent. + /// + /// + /// true if the TagEntry was validated to not be orphaned (not top level + no parents) and not be + /// self-parenting. + /// + /// Thrown when both Parents and ParentNames are empty. + public bool Validate() + { + if (!this.IsTopLevel && this.ParentIds.Count == 0 && this.Parents.Count == 0) + throw new InvalidOperationException(ErrorMessages.OrphanTagAttempt(this.Name)); + + if (this.Parents.Contains(this.Name) || this.ParentIds.Contains(this.Id)) + throw new InvalidOperationException(ErrorMessages.MakingSelfParentAttempt(this.Name)); + + return true; + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs b/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs new file mode 100644 index 0000000..7f9f213 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs @@ -0,0 +1,103 @@ +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +partial class TagDatabase +{ + /// + /// Destructively wipes all the tags inside the database. Mainly used for debugging and testing. + /// + /// true if successful. + public void ClearTags() + { + this.CheckInitialisation(); + using SqliteTransaction transaction = this.currentConnection.BeginTransaction(); + SqliteCommand deleteCommand = this.currentConnection.CreateCommand(); + deleteCommand.CommandText = """ + -- noinspection SqlWithoutWhere + DELETE FROM tag + """; + deleteCommand.Transaction = transaction; + try + { + deleteCommand.ExecuteNonQuery(); + transaction.Commit(); + this.Tags.Clear(); + } + catch (SqliteException) + { + transaction.Rollback(); + throw; + } + } + + /// + /// Deletes a from the . + /// + /// The ID of the tag to delete. + /// A representing the asynchronous operation. + public async Task DeleteTag(int id) + { + this.CheckInitialisation(); + Tag? targetTag = await this.SelectTagFromDatabase(id); + this.PerformDeletionChecks(targetTag); + await this.ExecuteTagDeletion(targetTag!); + } + + /// + /// Deletes a from the . + /// + /// The name of the tag to delete. + /// A representing the asynchronous operation. + public async Task DeleteTag(string name) + { + this.CheckInitialisation(); + Tag? targetTag = await this.SelectTagFromDatabase(name); + this.PerformDeletionChecks(targetTag); + await this.ExecuteTagDeletion(targetTag!); + } + + private void DeleteFromCache(Tag targetTag) + { + this.Tags.Where(tag => tag.ParentIds.Contains(targetTag.Id)) + .ToList() + .ForEach(tag => + { + tag.ParentIds.Remove(targetTag.Id); + tag.Parents.Remove(targetTag.Name); + }); + this.Tags.Remove(targetTag); + } + + private async Task ExecuteTagDeletion(Tag targetTag) + { + await using SqliteTransaction transaction = + (SqliteTransaction)await this.currentConnection!.BeginTransactionAsync().ConfigureAwait(false); + SqliteCommand command = this.currentConnection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + DELETE FROM tag + WHERE id == @tag_id + """; + command.Parameters.AddWithValue("@tag_id", targetTag.Id); + try + { + int count = Convert.ToInt32(await command.ExecuteNonQueryAsync().ConfigureAwait(false)); + if (count > 0) await transaction.CommitAsync().ConfigureAwait(false); + this.DeleteFromCache(targetTag); + } + catch (SqliteException) + { + await transaction.RollbackAsync().ConfigureAwait(false); + throw; + } + } + + private void PerformDeletionChecks(Tag? targetTag) + { + if (targetTag is null) throw new ArgumentException(ErrorMessages.TagNotFound); + + if (this.GetTagChildren(targetTag.Id).Count > 0) + throw new InvalidOperationException(ErrorMessages.TagHasChildren); + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.Events.cs b/Core/Models/TagDatabase/TagDatabase.Events.cs new file mode 100644 index 0000000..227f9c5 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.Events.cs @@ -0,0 +1,15 @@ +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// Event thrown when the database has successfully initialised and is ready for writing to. + /// + public event EventHandler InitialisationComplete = delegate { }; + + private void OnInitialisationComplete(EventArgs e) + { + this.Logger.Debug("OnInitialised invoked!"); + this.InitialisationComplete.Invoke(this, e); + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.ImportMethods.cs b/Core/Models/TagDatabase/TagDatabase.ImportMethods.cs new file mode 100644 index 0000000..5f817f1 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.ImportMethods.cs @@ -0,0 +1,64 @@ +using Microsoft.Data.Sqlite; +using TagHierarchyManager.Utilities; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + private async Task ImportAsync(Dictionary importDict) + { + if (this.currentConnection is null) + throw new InvalidOperationException(ErrorMessages.DbNotInitialised); + + await using SqliteTransaction transaction = + (SqliteTransaction)await this.currentConnection.BeginTransactionAsync().ConfigureAwait(false); + + try + { + // phase 1: add all the tags, without anything that relies on other tables. + foreach (ImportedTag tag in importDict.Values) await this.WriteImportedTagToDatabase(transaction, tag); + + this.Tags = await this.GetAllTagsFromDatabase(transaction: transaction).ConfigureAwait(false); + + // phase 2: add the parents and aliases. + foreach (ImportedTag tag in importDict.Values) + { + Tag? currentTag = this.Tags.SingleOrDefault(t => t.Name == tag.Name) + ?? await this.SelectTagFromDatabase(tag.Name).ConfigureAwait(false); + if (currentTag is null) + throw new InvalidOperationException(ErrorMessages.TagNotFound); + + await this.SaveTagAliases(transaction, currentTag.Id, tag.Aliases).ConfigureAwait(false); + await this.SaveTagParents(transaction, currentTag.Id, tag.Parents, currentTag).ConfigureAwait(false); + } + + await transaction.CommitAsync().ConfigureAwait(false); + } + catch + { + await transaction.RollbackAsync().ConfigureAwait(false); + throw; + } + } + + + private async Task WriteImportedTagToDatabase(SqliteTransaction transaction, ImportedTag tag) + { + if (this.currentConnection is null) + throw new InvalidOperationException(ErrorMessages.DbNotInitialised); + + SqliteCommand addCommand = this.currentConnection.CreateCommand(); + addCommand.Transaction = transaction; + addCommand.CommandText = """ + INSERT INTO tag (name, notes, top_level, tags_to_bind, also_known_as) + VALUES (@name, @notes, @is_top_level, @tags_to_bind, @aliases) + """; + addCommand.Parameters.AddWithValue("@name", tag.Name); + addCommand.Parameters.AddWithValue("@name_normalised", StringNormaliser.FormatStringForSearch(tag.Name)); + addCommand.Parameters.AddWithValue("@notes", tag.Notes); + addCommand.Parameters.AddWithValue("@is_top_level", tag.IsTopLevel ? 1 : 0); + addCommand.Parameters.AddWithValue("@tags_to_bind", string.Join(";", tag.TagBindings)); + addCommand.Parameters.AddWithValue("@aliases", string.Join(";", tag.Aliases)); + await addCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.Initialisation.cs b/Core/Models/TagDatabase/TagDatabase.Initialisation.cs new file mode 100644 index 0000000..cf70131 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.Initialisation.cs @@ -0,0 +1,312 @@ +using System.Data; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + private const string InMemoryDbName = "(temporary in-memory database)"; + private const string InMemoryDbPath = ":memory:"; + private const string TagHierarchyDbFileExt = ".thdb"; + + + /// + /// Throws an exception if the database has not been initialised yet. + /// + /// + /// Thrown when the database has not been initialised or currentConnection is + /// null. + /// + [MemberNotNull(nameof(currentConnection), nameof(Connection))] + private void CheckInitialisation() + { + if (!this.Initialised || this.currentConnection is null || this.Connection is null) + throw new InvalidOperationException(ErrorMessages.DbNotInitialised); + } + + /// + /// Checks if the current connection exists and is open, and closes it. + /// + public void Close() + { + if (this.currentConnection?.State != ConnectionState.Open) return; + this.currentConnection.Close(); + this.currentConnection.Dispose(); + this.Initialised = false; + } + + /// + /// Creates the database, fills the settings, then initialises the database object. + /// + /// Path to the database file. + /// Whether to overwrite the existing file. + /// A dictionary of tags to import. + /// A representing the asynchronous operation. + public async Task CreateAsync(string filePath, bool overwrite = false, + Dictionary? tagsToImport = null) + { + await this.CreateDatabaseAsync(filePath, overwrite, tagsToImport).ConfigureAwait(false); + } + + /// + /// Loads the database from an existing file. + /// + /// Path to the database file. + /// The to use, instead of a filePath. + /// A representing the asynchronous operation that returns a TagDatabase. + public async Task LoadAsync(string filePath = "", SqliteConnection? connection = null) + { + await this.LoadDatabaseAsync(filePath, connection).ConfigureAwait(false); + } + + private static string? ValidateFilePath(string filePath, bool loadMode = false) + { + if (filePath == InMemoryDbPath) return null; + + if (string.IsNullOrEmpty(filePath)) return ErrorMessages.FilePathIsEmpty; + + string fileExt = Path.GetExtension(filePath); + if (fileExt != TagHierarchyDbFileExt) return ErrorMessages.InvalidFileExtension; + + if (loadMode && !File.Exists(filePath)) return ErrorMessages.FilePathNotFound; + + return null; + } + + private async Task CreateDatabaseAsync(string filePath, bool overwrite = false, + Dictionary? tagsToImport = null) + { + try + { + if (overwrite && File.Exists(filePath)) File.Delete(filePath); + + string? errorString = ValidateFilePath(filePath); + if (errorString is not null) + { + throw new ArgumentException(errorString); + } + + this.currentConnection = new SqliteConnection($"Data Source={filePath};Pooling=False"); + + await this.currentConnection.OpenAsync().ConfigureAwait(false); + SqliteCommand command = this.currentConnection.CreateCommand(); + + command.CommandText = """ + CREATE TABLE "tag" ( + "id" INTEGER NOT NULL, + "name" TEXT NOT NULL UNIQUE, + "notes" TEXT DEFAULT '', + "top_level" INTEGER NOT NULL DEFAULT 0, + "tags_to_bind" TEXT, + "also_known_as" TEXT DEFAULT '', + PRIMARY KEY("id" AUTOINCREMENT) + ); + + CREATE TABLE "alias" ( + "id" INTEGER NOT NULL, + "tag_id" INTEGER NOT NULL, + "name" TEXT, + PRIMARY KEY("id" AUTOINCREMENT), + FOREIGN KEY("tag_id") REFERENCES "tag"("id") ON DELETE CASCADE + ); + + CREATE TABLE "tag_parent_link" ( + "target_tag_id" INT NOT NULL, + "parent_tag_id" INT NOT NULL CHECK("parent_tag_id" != "target_tag_id"), + FOREIGN KEY("parent_tag_id") REFERENCES "tag"("id") ON DELETE CASCADE, + FOREIGN KEY("target_tag_id") REFERENCES "tag"("id") ON DELETE CASCADE + ); + + CREATE TABLE "settings" ( + "key" TEXT NOT NULL UNIQUE, + "value" TEXT NOT NULL + ); + + INSERT INTO "main"."settings" ("key", "value") VALUES ('version', '1'); + INSERT INTO "main"."settings" ("key", "value") VALUES ('default_tag_bind', 'genre'); + + CREATE TRIGGER DoNotChangeRequiredKeys BEFORE UPDATE ON settings + FOR EACH ROW + WHEN OLD.key IN ('version', 'default_tag_bind') AND OLD.key != NEW.key + BEGIN + SELECT RAISE(ABORT,'CANNOT_CHANGE_REQUIRED_KEY'); + END; + + CREATE TRIGGER DoNotDeleteRequired + BEFORE DELETE ON settings + FOR EACH ROW + WHEN OLD.key IN ('version', 'default_tag_bind') + BEGIN + SELECT RAISE(ABORT, 'CANNOT_DELETE_REQUIRED_KEY'); + END; + """; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + await this.FinishInitialisationAsync(tagsToImport).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.Error(ex, "An error occurred: {ErrorMessage} ", ex.Message); + throw; + } + } + + /// + /// Initialises the tag hierarchy database. + /// + /// A representing the asynchronous operation. + private async Task FinishInitialisationAsync(Dictionary? tagsToImport = null) + { + this.Logger.Information("[TagHierarchyDatabase.Initialise] Initialising..."); + SqliteCommand command = this.currentConnection?.CreateCommand() ?? + throw new InvalidOperationException(ErrorMessages.DbNotInitialised); + command.CommandText = "SELECT * FROM SETTINGS;"; + try + { + await using (SqliteDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(false)) + { + while (await reader.ReadAsync().ConfigureAwait(false)) + { + string currentSetting = reader.GetString(0); + switch (currentSetting) + { + case "version": + this.Version = Convert.ToInt16(reader.GetString(1), CultureInfo.InvariantCulture); + break; + case "default_tag_bind": + this.DefaultTagBindings = reader.GetString(1) + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + break; + } + } + } + + this.FilePath = this.currentConnection.DataSource; + this.Name = this.currentConnection.DataSource != InMemoryDbPath + ? Path.GetFileNameWithoutExtension(this.currentConnection.DataSource) + : InMemoryDbName; + + this.Initialised = true; + + + if (tagsToImport is not null) + try + { + await this.ImportAsync(tagsToImport).ConfigureAwait(false); + } + catch + { + this.Close(); + throw; + } + + if (tagsToImport is null) + this.Tags = await this.GetAllTagsFromDatabase(); + + this.OnInitialisationComplete(EventArgs.Empty); + Debug.WriteLine( + $"[TagHierarchyDatabase.Initialise] Successfully initialised: Version {this.Version}, Name: {this.Name}, Default binding string: {this.DefaultTagBindings}"); + } + catch (SqliteException ex) + { + this.Logger.Error(ex, "An error occurred: {ErrorMessage} ", ex.Message); + throw; + } + } + + private async Task LoadDatabaseAsync(string filePath, SqliteConnection? connection = null) + { + if (connection is null) + { + string? errorString = ValidateFilePath(filePath, true); + if (errorString is not null) + { + throw new ArgumentException(errorString); + } + } + + this.currentConnection = connection ?? new SqliteConnection($"Data Source={filePath};Pooling=False"); + if (connection is null) await this.currentConnection.OpenAsync().ConfigureAwait(false); + + this.Logger.Debug("[TagDatabaseObject.Load] Connection opened: {@FilePath}", filePath); + + if (await this.ValidateAsync(this.currentConnection).ConfigureAwait(false)) + await this.FinishInitialisationAsync().ConfigureAwait(false); + } + + private async Task ValidateAsync(SqliteConnection connection) + { + bool sqliteDatabaseCheck = await this.ValidateFileIsSqliteDatabaseAsync(connection).ConfigureAwait(false); + bool structureCheck = await this.ValidateDatabaseStructureAsync(connection).ConfigureAwait(false); + + return sqliteDatabaseCheck && structureCheck; + } + + private async Task ValidateDatabaseStructureAsync(SqliteConnection connection) + { + try + { + SqliteCommand tableCheckCommand = connection.CreateCommand(); + tableCheckCommand.CommandText = """ + SELECT name FROM sqlite_master + WHERE name != 'sqlite_sequence' + AND type == 'table' + ORDER BY name + """; + this.Logger.Debug("[TagDatabaseObject.Load] Command created"); + bool notTagDatabase = false; + await using (SqliteDataReader reader = await tableCheckCommand.ExecuteReaderAsync().ConfigureAwait(false)) + { + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (notTagDatabase) continue; + string currentTable = reader.GetString(0); + HashSet allowedTables = ["tag", "tag_parent_link", "alias", "settings"]; + if (!allowedTables.Contains(currentTable)) notTagDatabase = true; + } + } + + if (notTagDatabase) + { + this.Close(); + throw new ArgumentException(ErrorMessages.DbNotValid); + } + } + catch (SqliteException ex) + { + this.Close(); + if (ex.SqliteErrorCode == 1) + throw new ArgumentException(ErrorMessages.DbNotValid); + throw; + } + + return true; + } + + private async Task ValidateFileIsSqliteDatabaseAsync(SqliteConnection connection) + { + try + { + SqliteCommand validityCheck = connection.CreateCommand(); + validityCheck.CommandText = "pragma schema_version;"; + int? schemaVersion = Convert.ToInt32(await validityCheck.ExecuteScalarAsync().ConfigureAwait(false), + CultureInfo.InvariantCulture); + if (schemaVersion == 0) + { + throw new ArgumentException(ErrorMessages.DbFileNotValid); + } + } + catch (SqliteException ex) + { + this.Close(); + if (ex.SqliteErrorCode == 26) + throw new ArgumentException(ErrorMessages.DbFileNotValid); + throw; + } + + return true; + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.QueryProcessorHandler.cs b/Core/Models/TagDatabase/TagDatabase.QueryProcessorHandler.cs new file mode 100644 index 0000000..0093a12 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.QueryProcessorHandler.cs @@ -0,0 +1,85 @@ +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// A class for handling the processing of queries to be sent to the . + /// + internal static class QueryProcessorHandler + { + private const string TagParentSelectionBaseCommand = """ + SELECT + parent_tag_id AS id, + parent.name, + parent.top_level, + parent.notes, + parent.tags_to_bind, + parent.also_known_as + FROM tag_parent_link + LEFT JOIN tag parent + ON tag_parent_link.parent_tag_id = parent.id + WHERE target_tag_id == @target_id + """; + + /// + /// Builds an SQLite SELECT command to retrieve the parents of a Tag. + /// + /// the SQLiteCommand object to add onto. + /// an integer corresponding to a Tag ID + internal static void ProcessTagParentSelectionCommand(SqliteCommand command, int id) + { + ProcessTagParentSelectionInt(command, id); + } + + /// + /// Generates an SQLITE INSERT command for saving the tag to the database. + /// + /// the SQLiteCommand object to add the generated command to. + /// the Tag object to process. + internal static void ProcessTagSaveCommand(SqliteCommand command, Tag tag) + { + if (tag.Id == 0) + { + // if tag.Id is null, it's assumed it's a new tag to be added. + command.CommandText = """ + INSERT INTO tag (name, notes, top_level, tags_to_bind, also_known_as) + VALUES (@name, @notes, @is_top_level, @tags_to_bind, @aliases) + RETURNING id; + """; + } + else + { + // ...otherwise, we know that this has been saved into the database. + command.CommandText = """ + UPDATE tag + SET + name = @name, + notes = @notes, + top_level = @is_top_level, + tags_to_bind = @tags_to_bind, + also_known_as = @aliases + WHERE id = @target_id + RETURNING id; + """; + command.Parameters.AddWithValue("@target_id", tag.Id); + } + + command.Parameters.AddWithValue("@name", tag.Name); + command.Parameters.AddWithValue("@notes", tag.Notes); + command.Parameters.AddWithValue("@is_top_level", tag.IsTopLevel ? 1 : 0); + command.Parameters.AddWithValue("@tags_to_bind", string.Join(';', tag.TagBindings)); + command.Parameters.AddWithValue("@aliases", string.Join(';', tag.Aliases)); + } + + private static void ProcessTagParentSelectionInt(SqliteCommand command, int id) + { + if (id == 0) + throw new ArgumentException(ErrorMessages.EmptyParameters); + + command.CommandText = TagParentSelectionBaseCommand; + command.Parameters.AddWithValue("@target_id", id); + } + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.ReadMethods.cs b/Core/Models/TagDatabase/TagDatabase.ReadMethods.cs new file mode 100644 index 0000000..ba54b78 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.ReadMethods.cs @@ -0,0 +1,208 @@ +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// Gets every tag inside the database, with an option to only grab top level tags. + /// + /// Whether to grab only top level tags from the database. + /// An optional SQLite transaction to run the command in. + /// + /// A representing the asynchronous operation, returning a + /// object containing all (or all top-level) s in the TagDatabase. + /// + public async Task> GetAllTagsFromDatabase(bool topLevelOnly = false, + SqliteTransaction? transaction = null) + { + this.CheckInitialisation(); + + SqliteCommand command = this.currentConnection.CreateCommand(); + if (transaction is not null) command.Transaction = transaction; + command.CommandText = """ + SELECT + tag.id, + tag.name, + tag.top_level, + tag.notes, + tag.tags_to_bind, + tag.also_known_as, + GROUP_CONCAT(tag_parent_link.parent_tag_id, ';') AS parent_ids + FROM tag + LEFT JOIN tag_parent_link ON tag.id = tag_parent_link.target_tag_id + GROUP BY tag.id + """; + + List tags = await this.ExecuteTagRetrievalDatabaseQuery(command).ConfigureAwait(false); + + tags.ForEach(tag => tag.Parents = tags + .Where(t => tag.ParentIds.Contains(t.Id)) + .Select(t => t.Name) + .ToList()); + if (topLevelOnly) + tags = tags.Where(tag => tag.IsTopLevel).ToList(); + return tags; + } + + /// + /// Gets the children of a . + /// + /// an integer corresponding to a 's ID + /// + /// A representing the asynchronous operation, returning a + /// object containing all children of a particular . + /// + public List GetTagChildren(int id) + { + List tags = this.Tags.Where(tag => tag.ParentIds.Contains(id)).ToList(); + return tags; + } + + /// + /// Gets the children of a . + /// + /// the string corresponding to a unique name. + /// + /// A representing the asynchronous operation, returning a + /// object containing all children of a particular . + /// + public List GetTagChildren(string name) + { + List tags = this.Tags.Where(tag => tag.Parents.Contains(name)).ToList(); + return tags; + } + + /// + /// Selects one specific tag by its exact name. + /// + /// The name of the tag. + /// An optional SQLite transaction to run the command in. + /// A representing the asynchronous operation, returning a Tag object or null. + public async Task SelectTagFromDatabase(string name, SqliteTransaction? transaction = null) + { + this.CheckInitialisation(); + SqliteCommand command = this.currentConnection.CreateCommand(); + if (transaction is not null) command.Transaction = transaction; + command.CommandText = """ + SELECT + tag.id, + tag.name, + tag.top_level, + tag.notes, + tag.tags_to_bind, + tag.also_known_as, + GROUP_CONCAT(tag_parent_link.parent_tag_id, ';') AS parent_ids + FROM tag + LEFT JOIN tag_parent_link ON tag.id = tag_parent_link.target_tag_id + WHERE tag.name = @tag_name + GROUP BY tag.id + """; + command.Parameters.AddWithValue("@tag_name", name); + List tags = await this.ExecuteTagRetrievalDatabaseQuery(command).ConfigureAwait(false); + Tag? selectedTag = tags.FirstOrDefault(); + if (selectedTag is null) return null; + + // Fetch parents within the same transaction if provided + SqliteCommand parentCommand = this.currentConnection.CreateCommand(); + if (transaction is not null) parentCommand.Transaction = transaction; + QueryProcessorHandler.ProcessTagParentSelectionCommand(parentCommand, selectedTag.Id); + List parents = await this.ExecuteTagRetrievalDatabaseQuery(parentCommand, false).ConfigureAwait(false); + selectedTag.ParentIds = parents.Select(p => p.Id).ToList(); + selectedTag.Parents = parents.Select(p => p.Name).ToList(); + + return selectedTag; + } + + /// + /// Selects one specific tag by its exact ID in the database. + /// + /// The ID of the tag as it exists on the database. + /// An optional SQLite transaction to run the command in. + /// A representing the asynchronous operation, returning a Tag object or null. + public async Task SelectTagFromDatabase(int id, SqliteTransaction? transaction = null) + { + this.CheckInitialisation(); + SqliteCommand command = this.currentConnection.CreateCommand(); + command.CommandText = """ + SELECT + tag.id, + tag.name, + tag.top_level, + tag.notes, + tag.tags_to_bind, + tag.also_known_as, + GROUP_CONCAT(tag_parent_link.parent_tag_id, ';') AS parent_ids + FROM tag + LEFT JOIN tag_parent_link ON tag.id = tag_parent_link.target_tag_id + WHERE tag.id = @tag_id + GROUP BY tag.id + """; + command.Parameters.AddWithValue("@tag_id", id); + List tags = await this.ExecuteTagRetrievalDatabaseQuery(command).ConfigureAwait(false); + Tag? selectedTag = tags.FirstOrDefault(); + if (selectedTag is null) return null; + + SqliteCommand parentCommand = this.currentConnection.CreateCommand(); + if (transaction is not null) parentCommand.Transaction = transaction; + QueryProcessorHandler.ProcessTagParentSelectionCommand(parentCommand, selectedTag.Id); + List parents = await this.ExecuteTagRetrievalDatabaseQuery(parentCommand, false).ConfigureAwait(false); + selectedTag.ParentIds = parents.Select(p => p.Id).ToList(); + selectedTag.Parents = parents.Select(p => p.Name).ToList(); + + return selectedTag; + } + + /// + /// Executes an SQLite command for retrieving tags and processing them into Tag objects. + /// + /// The SQLite command to process. + /// Whether the parents need to be fetched while running. + /// + /// A representing the asynchronous operation,returning a + /// containing objects, contents varying on the query in . + /// + private async Task> ExecuteTagRetrievalDatabaseQuery(SqliteCommand command, bool fetchParents = true) + { + List tags = []; + + try + { + await using SqliteDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + if (!reader.HasRows) return tags; + while (await reader.ReadAsync().ConfigureAwait(false)) + { + Tag addedTag = new() + { + Id = reader.GetInt32(reader.GetOrdinal(IdColumnName)), + Name = reader.GetString(reader.GetOrdinal(NameColumnName)), + IsTopLevel = reader.GetBoolean(reader.GetOrdinal(TopLevelColumnName)), + Notes = reader.GetString(reader.GetOrdinal(NotesColumnName)), + }; + + string tagBindList = reader.GetString(reader.GetOrdinal(TagBindingsColumnName)); + if (!string.IsNullOrEmpty(tagBindList)) addedTag.TagBindings = tagBindList.Split(';').ToList(); + + string altNameList = reader.GetString(reader.GetOrdinal(AliasesColumnName)); + if (!string.IsNullOrEmpty(altNameList)) addedTag.Aliases = altNameList.Split(';').ToList(); + + + if (fetchParents && !reader.IsDBNull(reader.GetOrdinal(ParentIdsColumnName))) + { + List parents = + reader.GetString(reader.GetOrdinal(ParentIdsColumnName)).Split(';').Select(int.Parse).ToList(); + addedTag.ParentIds = parents; + } + + tags.Add(addedTag); + } + + return tags; + } + catch (SqliteException ex) + { + this.Logger.Error(ex, "An error occurred: {ErrorMessage} ", ex.Message); + throw; + } + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.SearchMethods.cs b/Core/Models/TagDatabase/TagDatabase.SearchMethods.cs new file mode 100644 index 0000000..4b9f3d6 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.SearchMethods.cs @@ -0,0 +1,76 @@ +using TagHierarchyManager.Common; +using TagHierarchyManager.Utilities; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// Performs a search on the Tags list, the results dependent on the specified search mode. + /// + /// The string to search the tag names for. + /// The mode to search for, see . + /// A List of Tags representing the search results. + /// + /// Thrown if the mode is out of the valid range specified by TagDatabaseSearchMode. + /// + public List Search(string searchQuery, TagDatabaseSearchMode mode) + { + searchQuery = StringNormaliser.FormatStringForSearch(searchQuery.Trim()); + List tags = mode switch + { + TagDatabaseSearchMode.Fuzzy => this.Tags + .Where(tag => StringNormaliser.FormatStringForSearch(tag.Name).Contains(searchQuery)) + .ToList(), + TagDatabaseSearchMode.StartsWith => this.Tags + .Where(tag => StringNormaliser.FormatStringForSearch(tag.Name).StartsWith(searchQuery)) + .ToList(), + TagDatabaseSearchMode.EndsWith => this.Tags + .Where(tag => StringNormaliser.FormatStringForSearch(tag.Name).EndsWith(searchQuery)) + .ToList(), + TagDatabaseSearchMode.ExactMatch => this.Tags + .Where(tag => StringNormaliser.FormatStringForSearch(tag.Name) == searchQuery) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null), + }; + + return tags; + } + + /// + /// Performs a search on the Tags list, the results dependent on the specified search mode.
+ /// Searches tag aliases, as well as tag names. + ///
+ /// The string to search the tag names and aliases for. + /// The mode to search for, see . + /// A List of Tags representing the search results. + /// + /// Thrown if the mode is out of the valid range specified by TagDatabaseSearchMode. + /// + public List SearchWithAliases(string searchQuery, TagDatabaseSearchMode mode) + { + searchQuery = StringNormaliser.FormatStringForSearch(searchQuery.Trim().ToLowerInvariant()); + List tags = mode switch + { + TagDatabaseSearchMode.Fuzzy => this.Tags.Where(tag => + StringNormaliser.FormatStringForSearch(tag.Name).Contains(searchQuery) || tag.Aliases.Any(alias => + StringNormaliser.FormatStringForSearch(alias).Contains(searchQuery))) + .ToList(), + TagDatabaseSearchMode.StartsWith => this.Tags.Where(tag => + StringNormaliser.FormatStringForSearch(tag.Name).StartsWith(searchQuery) || tag.Aliases.Any(alias => + StringNormaliser.FormatStringForSearch(alias).StartsWith(searchQuery))) + .ToList(), + TagDatabaseSearchMode.EndsWith => this.Tags.Where(tag => + StringNormaliser.FormatStringForSearch(tag.Name).EndsWith(searchQuery) || tag.Aliases.Any(alias => + StringNormaliser.FormatStringForSearch(alias).EndsWith(searchQuery))) + .ToList(), + TagDatabaseSearchMode.ExactMatch => this.Tags.Where(tag => + StringNormaliser.FormatStringForSearch(tag.Name) == searchQuery || tag.Aliases.Any(alias => + StringNormaliser.FormatStringForSearch(alias) == searchQuery)) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null), + }; + + return tags; + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.SettingsHandler.cs b/Core/Models/TagDatabase/TagDatabase.SettingsHandler.cs new file mode 100644 index 0000000..e496058 --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.SettingsHandler.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// A class for handling settings for a at a lower level. + /// + /// The that owns the handler. + public sealed class SettingsHandler(TagDatabase db) + { + /// + /// The setting key for whichever tag binding should be default, if a tag binding doesn't get passed when adding to the + /// database. + /// + internal const string DefaultTagBindingKey = "default_tag_bind"; + + private const string SettingKeyParameter = "@setting_key"; + private const string SettingValueParameter = "@setting_value"; + private const string VersionKey = "version"; + + private readonly Dictionary defaultSettings = new() + { + { DefaultTagBindingKey, "genre" }, + }; + + private static IReadOnlyList RequiredSettingsKeys { get; } = + [VersionKey, DefaultTagBindingKey]; + + /// + /// Creates setting "key" with the given value. + /// + /// The key to add. + /// The value associated with this key. + /// true if the creation is successful. + /// Thrown if the key already exists. + /// Thrown if the database has not been properly initialised. + public async Task CreateSettingAsync(string key, string value) + { + db.CheckInitialisation(); + + if (await this.CheckSettingExistenceAsync(key).ConfigureAwait(false)) + throw new ArgumentException(ErrorMessages.SettingKeyAlreadyExists(key)); + SqliteCommand insertCommand = db.Connection.CreateCommand(); + + insertCommand.CommandText = $""" + INSERT INTO settings (key, value) + VALUES({SettingKeyParameter}, {SettingValueParameter}); + """; + insertCommand.Parameters.AddWithValue(SettingKeyParameter, key); + insertCommand.Parameters.AddWithValue(SettingValueParameter, value); + await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + db.Logger.Debug("Setting \"{@Key}\" with value \"{@Value}\" successfully added.", key, value); + } + + /// + /// Deletes a specific setting. + /// + /// The requested setting key. + /// True if deletion was successful. + /// Thrown if an attempt to delete a required setting was made. + /// Thrown if the key was not found. + public async Task DeleteSettingAsync(string key) + { + db.CheckInitialisation(); + + if (RequiredSettingsKeys.Contains(key)) + throw new InvalidOperationException(ErrorMessages.SettingIsRequired); + + if (!await this.CheckSettingExistenceAsync(key).ConfigureAwait(false)) + throw new KeyNotFoundException(ErrorMessages.SettingKeyNotFound(key)); + SqliteCommand command = db.Connection.CreateCommand(); + command.CommandText = $""" + DELETE FROM settings + WHERE key == {SettingKeyParameter} + """; + command.Parameters.AddWithValue(SettingKeyParameter, key); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// Grabs all settings from the settings table. + /// + /// Called if the tag hierarchy database has not been initialised, yet. + /// A with strings for keys and values, wrapped in a . + public async Task> GetAllSettingsAsync() + { + db.CheckInitialisation(); + Dictionary settingsDict = new(); + SqliteCommand command = db.Connection.CreateCommand(); + command.CommandText = "SELECT * FROM settings"; + await using SqliteDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + string key = reader.GetString(0); + string value = reader.GetString(1); + settingsDict.Add(key, value); + } + + return settingsDict; + } + + /// + /// Grab a specific setting's value from the settings table. + /// + /// The requested setting key. + /// The value of the setting. + /// Thrown if the key was not found. + public async Task GetSettingValueAsync(string key) + { + db.CheckInitialisation(); + + if (!await this.CheckSettingExistenceAsync(key).ConfigureAwait(false)) + throw new KeyNotFoundException(ErrorMessages.SettingKeyNotFound(key)); + SqliteCommand command = db.Connection.CreateCommand(); + command.CommandText = $""" + SELECT value FROM settings + WHERE key == {SettingKeyParameter} + """; + command.Parameters.AddWithValue(SettingKeyParameter, key); + string? pokedSetting = (string?)await command.ExecuteScalarAsync().ConfigureAwait(false); + return pokedSetting; + } + + /// + /// Sets the settings back to default based on the dictionary in the handler. + /// + public void ResetDefaultSettings() + { + db.DefaultTagBindings = this.defaultSettings[DefaultTagBindingKey] + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + + /// + /// Updates a specific setting's value. + /// + /// The requested setting key. + /// The new value for the setting. + /// true if the setting change was successful. + /// Thrown if the key was not found. + public async Task UpdateSettingAsync(string key, string value) + { + db.CheckInitialisation(); + + if (!await this.CheckSettingExistenceAsync(key).ConfigureAwait(false)) + throw new KeyNotFoundException(ErrorMessages.SettingKeyNotFound(key)); + SqliteCommand command = db.Connection.CreateCommand(); + command.CommandText = $""" + UPDATE settings + SET value = {SettingValueParameter} + WHERE key = {SettingKeyParameter}; + """; + command.Parameters.AddWithValue(SettingKeyParameter, key); + command.Parameters.AddWithValue(SettingValueParameter, + key == DefaultTagBindingKey ? string.Join(';', value) : value); + + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + return true; + } + + private async Task CheckSettingExistenceAsync(string key) + { + db.CheckInitialisation(); + + SqliteCommand command = db.Connection.CreateCommand(); + command.CommandText = $""" + SELECT COUNT(*) FROM settings + WHERE key = {SettingKeyParameter}; + """; + command.Parameters.AddWithValue(SettingKeyParameter, key); + int count = Convert.ToInt32(await command.ExecuteScalarAsync().ConfigureAwait(false), + CultureInfo.InvariantCulture); + return count > 0; + } + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs new file mode 100644 index 0000000..46ae43f --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using TagHierarchyManager.Utilities; + +namespace TagHierarchyManager.Models; + +public partial class TagDatabase +{ + /// + /// Saves the tag object to the database. + /// + /// The tag object. + /// The SqliteTransaction to execute queries on, will make its own if null. + /// true if the tag has been saved. + /// Thrown if the tag already existed in the database. + public async Task WriteTagToDatabase(Tag tag, SqliteTransaction? transaction = null) + { + this.CheckInitialisation(); + bool isTransactionOwner = transaction == null; + transaction ??= (SqliteTransaction)await this.currentConnection.BeginTransactionAsync().ConfigureAwait(false); + + // a database-associated tag will have an ID, and the program will know to edit it. + bool alreadyOnDatabase = tag.Id != 0; + + try + { + SqliteCommand addCommand = this.currentConnection.CreateCommand(); + addCommand.Transaction = transaction; + QueryProcessorHandler.ProcessTagSaveCommand(addCommand, tag); + + if (await this.SelectTagFromDatabase(tag.Name) is not null && !alreadyOnDatabase) + throw new ArgumentException(ErrorMessages.TagAlreadyExists(tag.Name)); + + tag.Id = Convert.ToInt32(await addCommand.ExecuteScalarAsync().ConfigureAwait(false), + CultureInfo.InvariantCulture); + + await this.SaveTagAliases(transaction, tag.Id, tag.Aliases).ConfigureAwait(false); + await this.SaveTagParents(transaction, tag.Id, tag.Parents, tag).ConfigureAwait(false); + + if (isTransactionOwner) await transaction.CommitAsync().ConfigureAwait(false); + + int index = this.Tags.FindIndex(t => t.Id == tag.Id); + if (index != -1) + this.Tags[index] = tag; + else + this.Tags.Add(tag); + } + catch (SqliteException) + { + await transaction.RollbackAsync().ConfigureAwait(false); + throw; + } + finally + { + if (isTransactionOwner) await transaction.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task SaveTagAliases(SqliteTransaction transaction, int id, IReadOnlyCollection aliases) + { + if (aliases.Count == 0) return; + this.CheckInitialisation(); + + SqliteCommand command = this.currentConnection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + "INSERT INTO alias (tag_id, name) VALUES (@tag_id, @name)"; + command.Parameters.Clear(); + command.Parameters.AddWithValue("@tag_id", id); + command.Parameters.Add("@name", SqliteType.Text); + command.Parameters.Add("@name_normalised", SqliteType.Text); + await command.PrepareAsync(); + + foreach (string alias in aliases) + { + command.Parameters["@name"].Value = alias; + command.Parameters["@name_normalised"].Value = StringNormaliser.FormatStringForSearch(alias); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } + + private async Task SaveTagParents(SqliteTransaction transaction, int id, IReadOnlyCollection parents, + Tag? tag = null) + { + if (parents.Count == 0) return; + this.CheckInitialisation(); + + List parentIds = []; + + // process parents, grabbing the names first in case the user wants to change the parents. + foreach (string parentName in parents) + { + Tag? retrievedTag = this.Tags.SingleOrDefault(t => t.Name == parentName) + ?? await this.SelectTagFromDatabase(parentName).ConfigureAwait(false); + if (retrievedTag is null) throw new ArgumentException(ErrorMessages.TagNotFound); + + parentIds.Add(retrievedTag.Id); + } + + // clear existing tag parents so we have a clean slate. + SqliteCommand deleteCommand = this.currentConnection.CreateCommand(); + deleteCommand.Transaction = transaction; + deleteCommand.CommandText = """ + DELETE FROM tag_parent_link + WHERE target_tag_id = @tag_id + """; + deleteCommand.Parameters.AddWithValue("@tag_id", id); + await deleteCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + + // process + add parent links + SqliteCommand parentCommand = this.currentConnection.CreateCommand(); + parentCommand.Transaction = transaction; + parentCommand.CommandText = """ + INSERT INTO tag_parent_link (target_tag_id, parent_tag_id) + VALUES (@target_tag_id, @parent_tag_id) + """; + parentCommand.Parameters.Clear(); + parentCommand.Parameters.AddWithValue("@target_tag_id", id); + parentCommand.Parameters.Add("@parent_tag_id", SqliteType.Integer); + await parentCommand.PrepareAsync(); + + foreach (int parentId in parentIds) + { + parentCommand.Parameters["@parent_tag_id"].Value = (long)parentId; + await parentCommand.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + // add tag parent IDs to tag if the tag was provided. + if (tag is not null) tag.ParentIds = parentIds; + } +} \ No newline at end of file diff --git a/Core/Models/TagDatabase/TagDatabase.cs b/Core/Models/TagDatabase/TagDatabase.cs new file mode 100644 index 0000000..125fbff --- /dev/null +++ b/Core/Models/TagDatabase/TagDatabase.cs @@ -0,0 +1,93 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using Serilog; + +namespace TagHierarchyManager.Models; + +/// +/// An object representing a Tag Hierarchy Manager database file. +/// +public partial class TagDatabase +{ + private const string AliasesColumnName = "also_known_as"; + private const string IdColumnName = "id"; + private const string NameColumnName = "name"; + private const string NotesColumnName = "notes"; + private const string ParentIdsColumnName = "parent_ids"; + private const string TagBindingsColumnName = "tags_to_bind"; + private const string TopLevelColumnName = "top_level"; + private SqliteConnection? currentConnection; + + private List defaultBindings = ["genre"]; + + /// + /// Initializes a new instance of the class. + /// + public TagDatabase() + { + this.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Debug(formatProvider: CultureInfo.InvariantCulture) + .CreateLogger(); + + this.Settings = new SettingsHandler(this); + } + + /// + /// Gets a for manipulating settings at a lower level. + /// + public SettingsHandler Settings { get; } + + /// + /// Gets or sets a List(string) of tag binding(s) that will be added to a tag by default. Defaults to genre + /// (e.g. Festival Progressive House ::genre). + /// + public List DefaultTagBindings + { + get => this.defaultBindings; + set + { + if (this.defaultBindings == value) return; + this.defaultBindings = value; + if (this.Initialised) + _ = this.Settings.UpdateSettingAsync(SettingsHandler.DefaultTagBindingKey, string.Join(';', value)); + } + } + + /// + /// Gets the location of the .thdb file associated with the , inferred from the current + /// connection's data source on initialisation. + /// + // ReSharper disable once UnusedAutoPropertyAccessor.Global + public string? FilePath { get; private set; } + + /// + /// Gets the name of the database, inferred from the source file's name. + /// + public string Name { get; private set; } = string.Empty; + + /// + /// Gets the list of s in the database. + /// + public List Tags { get; private set; } = []; + + /// + /// Gets version of the database. Cannot be set outside of initialisation. + /// + public int Version { get; private set; } + + /// + /// Gets the SQLite connection associated with the . + /// + private SqliteConnection? Connection => this.currentConnection; + + /// + /// Gets a Logger object, using the Serilog library. + /// + private ILogger Logger { get; } + + /// + /// Gets a value indicating whether the database has been initialised or not. + /// + private bool Initialised { get; set; } +} \ No newline at end of file diff --git a/Core/TagHierarchyManager.csproj b/Core/TagHierarchyManager.csproj new file mode 100644 index 0000000..8996977 --- /dev/null +++ b/Core/TagHierarchyManager.csproj @@ -0,0 +1,19 @@ + + + + Library + net9.0 + enable + enable + false + true + true + + + + + + + + + diff --git a/Core/TagHierarchyManager.csproj.DotSettings b/Core/TagHierarchyManager.csproj.DotSettings new file mode 100644 index 0000000..f6e0ad5 --- /dev/null +++ b/Core/TagHierarchyManager.csproj.DotSettings @@ -0,0 +1,6 @@ + + False + True + True + True + True \ No newline at end of file diff --git a/Core/Utilities/StringNormaliser.cs b/Core/Utilities/StringNormaliser.cs new file mode 100644 index 0000000..f790cd6 --- /dev/null +++ b/Core/Utilities/StringNormaliser.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using System.Text; + +namespace TagHierarchyManager.Utilities; + +/// +/// A utility class for normalising strings for the search handler. +/// +public static class StringNormaliser +{ + /// + /// Strips out any diacritics from the input so it can be searched with regular Latin alphabet keys. + /// + /// The string to be normalised. + /// with the diacritics stripped. + public static string FormatStringForSearch(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + string decomposedString = input.Normalize(NormalizationForm.FormD); + + StringBuilder sb = new(); + foreach (char c in decomposedString.Where(c => + CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)) sb.Append(c); + + return sb.ToString().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/TagHierarchyManager.sln b/TagHierarchyManager.sln new file mode 100644 index 0000000..9ea0ac1 --- /dev/null +++ b/TagHierarchyManager.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager", "Core\TagHierarchyManager.csproj", "{E12B6C33-67E7-439C-8AD6-FF2510556AFE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{397693A2-4BD9-4EA2-B70E-84B0B4DD2BF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager.Tests", "Tests\TagHierarchyManager.Tests.csproj", "{F26B4A2D-0389-4FE8-8B0D-E491B354AC83}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UI", "UI", "{9666D8FC-785C-4F34-B224-21636441C508}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager.UI.TerminalUI", "UI\Terminal\TagHierarchyManager.UI.TerminalUI.csproj", "{FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E12B6C33-67E7-439C-8AD6-FF2510556AFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E12B6C33-67E7-439C-8AD6-FF2510556AFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E12B6C33-67E7-439C-8AD6-FF2510556AFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E12B6C33-67E7-439C-8AD6-FF2510556AFE}.Release|Any CPU.Build.0 = Release|Any CPU + {F26B4A2D-0389-4FE8-8B0D-E491B354AC83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F26B4A2D-0389-4FE8-8B0D-E491B354AC83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F26B4A2D-0389-4FE8-8B0D-E491B354AC83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F26B4A2D-0389-4FE8-8B0D-E491B354AC83}.Release|Any CPU.Build.0 = Release|Any CPU + {FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F26B4A2D-0389-4FE8-8B0D-E491B354AC83} = {397693A2-4BD9-4EA2-B70E-84B0B4DD2BF9} + {FC98B5BA-1E3D-42F2-8A32-CDDAD1FEDF72} = {9666D8FC-785C-4F34-B224-21636441C508} + EndGlobalSection +EndGlobal diff --git a/Tests/ExporterTests.cs b/Tests/ExporterTests.cs new file mode 100644 index 0000000..5bcefeb --- /dev/null +++ b/Tests/ExporterTests.cs @@ -0,0 +1,58 @@ +using NUnit.Framework; +using TagHierarchyManager.Exporters; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to exporter classes. +/// +[TestFixture] +public class ExporterTests : TestBase +{ + /// + /// Tests to see if the test database exports to a MusicBee tag hierarchy template correctly. + /// + /// A representing the asynchronous test. + [Test] + public void ExportAsync_ExportToMusicBeeTagHierarchy_ExportedStringMatches() + { + // Arrange + const string expectedExport = """ + Ambient + Ambient::genre + Ambient::style + Dark Ambient + Dark Ambient::genre + Dark Ambient::style + Ritual Ambient::genre + Ritual Ambient::style + Space Ambient::genre + Space Ambient::style + Tribal Ambient::genre + Tribal Ambient::style + Electronic + Electronic::genre + Electronic::style + Space Ambient::genre + Space Ambient::style + Industrial & Noise + Industrial & Noise::genre + Industrial & Noise::style + Post-Industrial + Post-Industrial::genre + Post-Industrial::style + Dark Ambient + Dark Ambient::genre + Dark Ambient::style + Ritual Ambient::genre + Ritual Ambient::style + """; + + // Act + string exportedTagHierarchy = new MusicBeeTagHierarchyExporter().ExportDatabase(this.Database); + exportedTagHierarchy = exportedTagHierarchy.TrimEnd(); + + // Assert + Assert.That(exportedTagHierarchy, Is.EqualTo(expectedExport)); + } +} \ No newline at end of file diff --git a/Tests/ImporterTests.cs b/Tests/ImporterTests.cs new file mode 100644 index 0000000..a8fed35 --- /dev/null +++ b/Tests/ImporterTests.cs @@ -0,0 +1,137 @@ +using NUnit.Framework; +using TagHierarchyManager.Exporters; +using TagHierarchyManager.Importers; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to importer classes. +/// +[TestFixture] +public class ImporterTests : TestBase +{ + /// + /// Tests if, when importing a MusicBee tag hierarchy template, it is exported back out to the same output. + /// + /// A representing the asynchronous test. + [Test] + public async Task ImportAsync_ImportMusicBeeTagHierarchy_ExportedStringMatches() + { + const string expectedExport = """ + Genres + Ambient + Ambient::genre + Ambient::style + Dark Ambient + Dark Ambient::genre + Dark Ambient::style + Ritual Ambient::genre + Ritual Ambient::style + Space Ambient::genre + Space Ambient::style + Tribal Ambient::genre + Tribal Ambient::style + Electronic + Electronic::genre + Electronic::style + Space Ambient::genre + Space Ambient::style + Industrial & Noise + Industrial & Noise::genre + Industrial & Noise::style + Post-Industrial + Post-Industrial::genre + Post-Industrial::style + Dark Ambient + Dark Ambient::genre + Dark Ambient::style + Ritual Ambient::genre + Ritual Ambient::style + Scenes & Movements + Demoscene::movement + """; + string tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, expectedExport); + + + // Act + TagDatabase db = new(); + Dictionary testData = + await new MusicBeeTagHierarchyImporter().ImportFromFileAsync(tempFilePath); + await db.CreateAsync(":memory:", tagsToImport: testData); + + string exportedTagHierarchy = new MusicBeeTagHierarchyExporter().ExportDatabase(db); + exportedTagHierarchy = exportedTagHierarchy.TrimEnd(); + + // Assert + Assert.That(exportedTagHierarchy, Is.EqualTo(expectedExport.TrimEnd())); + } + + // the below should be unit tests really, but I'll do that when I've actually wrapped my head around mocks and stuff. + // I'd rather reliable integration tests than unreliable, crappy unit tests. + // + // Also, the below test disables StyleCop's SA1027 (Tabs and spaces must be used correctly) + // as they are deliberately malformed for testing purposes. + + /// + /// Tests for if s are thrown when attempting to import a tag hierarchy with tabs or + /// that starts with a space. + /// + /// The string to attempt to send to the importer. + /// The expected error message. + [Test] + [TestCase( +#pragma warning disable SA1027 + """ + Ambient + Ambient::genre + Ambient::style + """, MusicBeeTagHierarchyImporter.ErrorMessages.TagHierarchyTabsDetected)] + [TestCase(" Ambient", MusicBeeTagHierarchyImporter.ErrorMessages.TagHierarchyStartsWithSpace)] + public async Task ImportAsync_ImportMusicBeeTagHierarchy_ArgumentExceptionThrown(string brokenHierarchy, + string exceptionMessage) + { + string tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, brokenHierarchy); + + Importer importer = new MusicBeeTagHierarchyImporter(); + Exception? ex = + Assert.ThrowsAsync(async () => await importer.ImportFromFileAsync(tempFilePath)); + Assert.That(ex!.Message, Is.EqualTo(exceptionMessage)); + } + + /// + /// Tests for if s are thrown on particular edge cases relating to the spacing of tags + /// in a tag hierarchy template.
+ /// These are for exceptions where a line number is expected to be included in the error message. + ///
+ /// The string to attempt to send to the importer. + /// The line number to be expected. + /// The expected error message template. + [Test] + [TestCase( + """ + Ambient + Ambient::genre + Ambient::style + """, 2, MusicBeeTagHierarchyImporter.ErrorMessages.IndentIsUnevenTemplate)] + [TestCase( + """ + Ambient + Ambient::genre + Ambient::style + """, 2, MusicBeeTagHierarchyImporter.ErrorMessages.IndentIsExcessiveTemplate)] + public async Task ImportAsync_ImportMusicBeeTagHierarchy_TagHierarchyDataExceptionThrown(string brokenHierarchy, + int lineNumber, string exceptionMessage) + { + // Arrange + string expectedExceptionMessage = string.Format(exceptionMessage, lineNumber); + string tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, brokenHierarchy); + Importer importer = new MusicBeeTagHierarchyImporter(); + Exception? ex = + Assert.ThrowsAsync(async () => await importer.ImportFromFileAsync(tempFilePath)); + Assert.That(ex!.Message, Is.EqualTo(expectedExceptionMessage)); + } +} \ No newline at end of file diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..4941f8d --- /dev/null +++ b/Tests/README.md @@ -0,0 +1 @@ +These are mainly integration tests. I still need to write unit tests but for now, these'll do. diff --git a/Tests/SettingsTests.cs b/Tests/SettingsTests.cs new file mode 100644 index 0000000..c5a776a --- /dev/null +++ b/Tests/SettingsTests.cs @@ -0,0 +1,201 @@ +using NUnit.Framework; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests for using the at a lower level. +/// +[TestFixture] +public class SettingsTests : TestBase +{ + private const string AddedTestKey = "testkey"; + private const string AddedTestValue = "testvalue"; + private const string NonExistentKey = "testkeydoesntexist"; + + /// + /// Tests if a setting can be successfully created. + /// + /// A representing the asynchronous test. + [Test] + public async Task CreateSetting_SuccessfulSettingCreation() + { + // Arrange/Act + await this.Database.Settings.CreateSettingAsync(AddedTestKey, AddedTestValue); + string? retrievedSetting = await this.Database.Settings.GetSettingValueAsync(AddedTestKey); + + // Assert + Assert.That(retrievedSetting, Is.EqualTo(AddedTestValue)); + } + + /// + /// Checks if adding a setting that already exists results in an ArgumentException being thrown. + /// + /// A representing the asynchronous test. + [Test] + public async Task CreateSetting_ThrowArgumentExceptionIfAlreadyExists() + { + // Arrange + await this.AddTestSetting(); + + // Act/Assert + ArgumentException ex = Assert.ThrowsAsync(async () => + await this.Database.Settings.CreateSettingAsync(AddedTestKey, AddedTestValue)) !; + Assert.That(ex.Message, Is.EqualTo(ErrorMessages.SettingKeyAlreadyExists(AddedTestKey))); + } + + /// + /// DefaultTagBindings has a setter that automatically updates on the database when it itself is updated. This tests if + /// the setting successfully updates. + /// + /// A representing the asynchronous test. + [Test] + public async Task DbProperties_SetViaProperty_ChangedDefaultTagBindings() + { + // Arrange + List changedTagBindings = ["genre", "album genre"]; + + // Act + this.Database.DefaultTagBindings = changedTagBindings; + + // Assert + string? settingValueString = await this.Database.Settings.GetSettingValueAsync(ExpectedTagBindKey); + List changedTagBindingsList = settingValueString! + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + Assert.That(settingValueString, Is.EqualTo(string.Join(';', this.Database.DefaultTagBindings))); + Assert.That(changedTagBindingsList, Is.EquivalentTo(changedTagBindings)); + } + + /// + /// Tests if the custom test setting can be deleted. + /// + /// A representing the asynchronous test. + [Test] + public async Task DeleteSetting_CustomSettingDeleted() + { + // Arrange + await this.AddTestSetting(); + + // Act + await this.Database.Settings.DeleteSettingAsync(AddedTestKey); + + // Assert + Assert.ThrowsAsync(async () => + await this.Database.Settings.GetSettingValueAsync(AddedTestKey)); + } + + /// + /// Tests if, when attempting to delete a setting whose key is part of the required setting keys, an + /// is thrown. + /// + /// The key to evaluate. + [Test] + [TestCase(ExpectedVersionKey)] + [TestCase(ExpectedTagBindKey)] + public void DeleteSetting_ThrowInvalidOperationExceptionIfSettingIsRequired(string key) + { + Assert.ThrowsAsync(async () => await this.Database.Settings.DeleteSettingAsync(key)); + } + + /// + /// Tests if, when attempting to delete a setting with a key that doesn't exist, a KeyNotFoundException is thrown. + /// + [Test] + public void DeleteSetting_ThrowKeyNotFoundExceptionIfSettingNotFound() + { + KeyNotFoundException ex = Assert.ThrowsAsync(async () => + await this.Database.Settings.DeleteSettingAsync(NonExistentKey)) !; + Assert.That(ex.Message, Is.EqualTo(ErrorMessages.SettingKeyNotFound(NonExistentKey))); + } + + /// + /// Tests if the required setting keys exist. + /// + /// The key to validate. + /// A representing the asynchronous test. + [Test] + [TestCase(ExpectedVersionKey)] + [TestCase(ExpectedTagBindKey)] + public async Task GetAllSettings_RequiredSettingsKeysValidated(string key) + { + // Arrange/Act + Dictionary retrievedKeys = await this.Database.Settings.GetAllSettingsAsync(); + + // Assert + Assert.That(retrievedKeys.Keys, Contains.Item(key)); + } + + /// + /// Checks if the custom test setting is present when using GetAllSettingAsync. + /// + /// A representing the asynchronous test. + [Test] + public async Task GetAllSettings_WithCustomSetting_CustomSettingInAllSettings() + { + // Arrange + await this.AddTestSetting(); + + // Act + Dictionary retrievedKeys = await this.Database.Settings.GetAllSettingsAsync(); + + // Assert + Assert.That(retrievedKeys.Keys, Contains.Item(AddedTestKey)); + Assert.That(retrievedKeys[AddedTestKey], Is.EqualTo(AddedTestValue)); + } + + /// + /// Sets the settings back to default on every set up. + /// + [SetUp] + public void ResetDefaultSettings() + { + this.Database.Settings.ResetDefaultSettings(); + } + + /// + /// Checks if the custom test setting can be updated. + /// + /// A representing the asynchronous test. + [Test] + public async Task UpdateSetting_SettingUpdated() + { + // Arrange + await this.AddTestSetting(); + const string expectedUpdatedValue = "22222"; + + // Act + await this.Database.Settings.UpdateSettingAsync(AddedTestKey, expectedUpdatedValue); + + // Assert + await Assert.ThatAsync(async () => await this.Database.Settings.GetSettingValueAsync(AddedTestKey), + Is.EqualTo(expectedUpdatedValue)); + } + + /// + /// Tests if, when trying to find a setting key that does not exist in the 's settings, a + /// KeyNotFoundException is thrown. + /// + [Test] + public void UpdateSetting_ThrowKeyNotFoundExceptionIfSettingNotFound() + { + KeyNotFoundException? ex = Assert.ThrowsAsync(async () => + await this.Database.Settings.UpdateSettingAsync(NonExistentKey, "testvalue")); + Assert.That(ex!.Message, Is.EqualTo(ErrorMessages.SettingKeyNotFound(NonExistentKey))); + } + + /// + /// Adds the test setting if it doesn't exist. + /// + /// A representing the asynchronous test. + private async Task AddTestSetting() + { + try + { + await this.Database.Settings.GetSettingValueAsync(AddedTestKey); + } + catch (KeyNotFoundException) + { + await this.Database.Settings.CreateSettingAsync(AddedTestKey, AddedTestValue); + } + } +} \ No newline at end of file diff --git a/Tests/TagDatabaseInitTests.cs b/Tests/TagDatabaseInitTests.cs new file mode 100644 index 0000000..3b9f30f --- /dev/null +++ b/Tests/TagDatabaseInitTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Data.Sqlite; +using NUnit.Framework; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to initialisation. +/// +[TestFixture] +public class TagDatabaseInitTests : TestBase +{ + private static readonly string TestDbsDir = Path.Combine(Path.GetTempPath(), "_taghierarchymanager_tests"); + + /// + /// Sets up the temporary folder to store test files in. + /// + [OneTimeSetUp] + public void SetUpTempFolder() + { + Directory.CreateDirectory(TestDbsDir); + } + + /// + /// Tests if an initialisation error relating to the file not being a valid database are being sent. + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabaseInit_LoadTagDatabase_InitialisationErrorOnFileNotValidDatabase() + { + // Arrange + const string invalidDbName = "invalid_file.thdb"; + string invalidDbPath = Path.Combine(TestDbsDir, invalidDbName); + try + { + await using FileStream file = File.Open(invalidDbPath, FileMode.CreateNew); + } + catch (IOException) + { + // do nothing as the file exists already. + } + + TagDatabase dummyDb = new(); + Exception? ex = Assert.ThrowsAsync(async () => await dummyDb.LoadAsync(invalidDbPath)); + + // Act/Assert + Assert.That(ex?.Message, Is.EqualTo(ErrorMessages.DbFileNotValid)); + } + + /// + /// Tests if initialisation errors relating to the file name are being sent correctly. + /// + /// The file name to test. + /// The expected error message. + /// A representing the asynchronous test. + [Test] + [TestCase("", ErrorMessages.FilePathIsEmpty)] + [TestCase("invalid_file.exe", ErrorMessages.InvalidFileExtension)] + public void TagDatabaseInit_NewTagDatabase_ErrorCaughtFileBased(string fileName, string errorMessage) + { + TagDatabase invalidDb = new(); + Exception? ex = Assert.ThrowsAsync(async () => await invalidDb.CreateAsync(fileName)); + + Assert.That(ex?.Message, Is.EqualTo(errorMessage)); + } + + /// + /// Tests if an initialisation error relating to an invalid table structure is being sent. + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabaseInit_NewTagDatabase_InitialisationErrorOnInvalidTableStructure() + { + // Arrange + SqliteConnection invalidConnection = new("Data Source=:memory:"); + await invalidConnection.OpenAsync(); + SqliteCommand command = invalidConnection.CreateCommand(); + command.CommandText = """ + CREATE TABLE "invalid" ( + "id" INTEGER + ); + """; + await command.ExecuteNonQueryAsync(); + + Exception? ex = + Assert.ThrowsAsync(async () => + await this.Database.LoadAsync(connection: invalidConnection)); + + // Act/Assert + Assert.That(ex?.Message, Is.EqualTo(ErrorMessages.DbNotValid)); + } + + /// + /// Tests if an initialised database is created and initialised. + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabaseInit_NewTagDatabase_ReturnsInitializedDatabase() + { + // Arrange/Act + bool isInitialised = false; + TagDatabase db = new(); + db.InitialisationComplete += (_, _) => { isInitialised = true; }; + await db.CreateAsync(":memory:"); + + // Assert + Assert.That(db, Is.Not.Null); + Assert.That(isInitialised, Is.True); + } +} \ No newline at end of file diff --git a/Tests/TagDatabaseSaveTests.cs b/Tests/TagDatabaseSaveTests.cs new file mode 100644 index 0000000..5d97a04 --- /dev/null +++ b/Tests/TagDatabaseSaveTests.cs @@ -0,0 +1,189 @@ +using NUnit.Framework; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to saving of objects to a . +/// +[TestFixture] +public class TagDatabaseWriteTests : TestBase +{ + private static IEnumerable WriteTagTestCases + { + get + { + yield return new TestCaseData(TestSampleTags.SpaceAmbient); + yield return new TestCaseData(TestSampleTags.TribalAmbient); + } + } + + /// + /// Clears the database and adds some sample data. + /// + /// A representing the asynchronous test. + [SetUp] + public async Task ClearDatabaseAndAddSampleData() + { + this.Database.ClearTags(); + await this.Database.WriteTagToDatabase(TestSampleTags.Ambient); + await this.Database.WriteTagToDatabase(TestSampleTags.Electronic); + } + + /// + /// Tests if deletion of a tag works, by checking if the result of trying to select that tag's name returns null. + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabase_DeleteTag_TagIsDeleted() + { + // Arrange + Tag deletedTag = new() + { + Name = "DELETE ME", + IsTopLevel = true, + }; + await this.Database.WriteTagToDatabase(deletedTag); + + // Act + await this.Database.DeleteTag(deletedTag.Id); + + // Assert + await Assert.ThatAsync(async () => await this.Database.SelectTagFromDatabase(deletedTag.Name), Is.Null); + } + + /// + /// Tests if an is thrown on deleting a tag that does not exist. + /// + [Test] + public void TagDatabase_DeleteTag_ThrowExceptionOnDeletingNonExistentTag() + { + using (Assert.EnterMultipleScope()) + { + ArgumentException? exName = Assert.ThrowsAsync(async () => + await this.Database.DeleteTag("This tag does not exist")); + ArgumentException? exId = + Assert.ThrowsAsync(async () => await this.Database.DeleteTag(1000)); + Assert.That(exName?.Message, Is.EqualTo(ErrorMessages.TagNotFound)); + Assert.That(exId?.Message, Is.EqualTo(ErrorMessages.TagNotFound)); + } + } + + /// + /// Tests if a tag is being edited and saved to the successfully. + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabase_WriteTagToDatabase_EditTag() + { + // Arrange + Tag firstParentTag = new() + { + Name = "Test parent tag 1", + IsTopLevel = true, + }; + Tag secondParentTag = new() + { + Name = "Test parent tag 2", + IsTopLevel = true, + }; + Tag childTag = new() + { + Name = "Test child tag", + IsTopLevel = false, + TagBindings = ["genre"], + Parents = ["Test parent tag 1"], + }; + await this.Database.WriteTagToDatabase(firstParentTag); + await this.Database.WriteTagToDatabase(secondParentTag); + await this.Database.WriteTagToDatabase(childTag); + int childTagId = childTag.Id; + List expectedParents = [firstParentTag.Id, secondParentTag.Id]; + + const string newName = "Test child tag (edited)"; + const bool newTopLevel = true; + const string addedTagBind = "style"; + const string addedParentName = "Test parent tag 2"; + const string newNotes = "Test note edit"; + const string newAlias = "Test child tag (alias added)"; + List newAliases = [newAlias]; + + // Act + childTag.Name = newName; + childTag.IsTopLevel = newTopLevel; + childTag.TagBindings.Add(addedTagBind); + childTag.Parents.Add(addedParentName); + childTag.Notes = newNotes; + childTag.Aliases = newAliases; + await this.Database.WriteTagToDatabase(childTag); + + // Assert + Tag? editedChildTag = await this.Database.SelectTagFromDatabase(childTagId); + const int expectedParentCount = 2; + const int expectedAliasCount = 1; + + using (Assert.EnterMultipleScope()) + { + Assert.That(editedChildTag!.Id, Is.GreaterThan(0)); + Assert.That(editedChildTag.ParentIds.Count, Is.EqualTo(expectedParentCount)); + Assert.That(editedChildTag.ParentIds, Is.EquivalentTo(expectedParents)); + Assert.That(editedChildTag.Aliases.Count, Is.EqualTo(expectedAliasCount)); + Assert.That(editedChildTag.Aliases, Does.Contain(newAlias)); + } + } + + /// + /// Tests if an attempt to save a tag that already exists results in an . + /// + [Test] + public void TagDatabase_WriteTagToDatabase_ThrowExceptionOnTagAlreadyExists() + { + // Arrange + Tag ambient = TestSampleTags.Ambient; + + // Act/Assert + ArgumentException? ex = + Assert.ThrowsAsync(async () => await this.Database.WriteTagToDatabase(ambient)); + Assert.That(ex!.Message, Does.EndWith("already exists in the database.")); + } + + /// + /// Tests if tags are being saved to and retrieved from the successfully. + /// + /// The tag to save. + /// A representing the asynchronous test. + [Test] + [TestCaseSource(nameof(WriteTagTestCases))] + public async Task TagDatabase_WriteTagToDatabase_WriteTag(Tag inputTag) + { + // Arrange + bool tagValidated = inputTag.Validate(); + + // Act + await this.Database.WriteTagToDatabase(inputTag); + Tag? savedTag = await this.Database.SelectTagFromDatabase(inputTag.Name); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(tagValidated, Is.True); + Assert.That(inputTag.Id, Is.GreaterThan(0)); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(savedTag!.Parents.Count, Is.EqualTo(inputTag.Parents.Count)); + Assert.That(savedTag.Parents, Is.EquivalentTo(inputTag.Parents)); + Assert.That(savedTag.ParentIds.Count, Is.EqualTo(inputTag.Parents.Count)); + List parentTags = await savedTag.ParentIds.ToAsyncEnumerable() + .SelectAwait(async parentId => + { + Tag? tag = await this.Database.SelectTagFromDatabase(parentId); + return tag!.Name; + }).ToListAsync(); + Assert.That(parentTags, Is.EquivalentTo(inputTag.Parents)); + Assert.That(savedTag.Aliases.Count, Is.EqualTo(inputTag.Aliases.Count)); + Assert.That(savedTag.Aliases, Is.EquivalentTo(inputTag.Aliases)); + } + } +} \ No newline at end of file diff --git a/Tests/TagDatabaseSearchTests.cs b/Tests/TagDatabaseSearchTests.cs new file mode 100644 index 0000000..cd4f3e6 --- /dev/null +++ b/Tests/TagDatabaseSearchTests.cs @@ -0,0 +1,199 @@ +using NUnit.Framework; +using TagHierarchyManager.Common; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to search functionality. +/// +[TestFixture] +public class TagDatabaseSearchTests : TestBase +{ + private const string TestQuery = "ambient"; + + private readonly Dictionary> expectedSearchResults = new() + { + { + "NoAKAsFuzzy", + TestSampleTags.AllTags() + .Where(tag => tag.Name.Contains(TestQuery, StringComparison.CurrentCultureIgnoreCase)) + .Select(tag => tag.Name) + .ToList() + }, + { + "NoAKAsStartsWith", + TestSampleTags.AllTags() + .Where(tag => tag.Name.StartsWith(TestQuery, StringComparison.CurrentCultureIgnoreCase)) + .Select(tag => tag.Name) + .ToList() + }, + { + "NoAKAsEndsWith", + TestSampleTags.AllTags() + .Where(tag => tag.Name.ToLower().EndsWith(TestQuery, StringComparison.CurrentCultureIgnoreCase)) + .Select(tag => tag.Name) + .ToList() + }, + { + "NoAKAsExactMatch", + TestSampleTags.AllTags() + .Where(tag => tag.Name.ToLower() == TestQuery.ToLower()) + .Select(tag => tag.Name) + .ToList() + }, + { + "WithAKAsFuzzy", + TestSampleTags.AllTags() + .Where(tag => + tag.Aliases.Any(alias => alias.ToLower().Contains(TestQuery, StringComparison.OrdinalIgnoreCase)) || + tag.Name.ToLower().Contains(TestQuery)) + .Select(tag => tag.Name) + .ToList() + }, + { + "WithAKAsStartsWith", + TestSampleTags.AllTags() + .Where(tag => + tag.Aliases.Any(alias => alias.StartsWith(TestQuery, StringComparison.OrdinalIgnoreCase)) || + tag.Name.ToLower().StartsWith(TestQuery, StringComparison.OrdinalIgnoreCase)) + .Select(tag => tag.Name) + .ToList() + }, + { + "WithAKAsEndsWith", + TestSampleTags.AllTags() + .Where(tag => tag.Aliases.Any(alias => + alias.EndsWith(TestQuery, StringComparison.OrdinalIgnoreCase)) || + tag.Name.ToLower().EndsWith(TestQuery, StringComparison.OrdinalIgnoreCase)) + .Select(tag => tag.Name) + .ToList() + }, + { + "WithAKAsExactMatch", + TestSampleTags.AllTags().Where(tag => tag.Aliases.Any(alias => alias.ToLower() == TestQuery.ToLower()) || + tag.Name.ToLower() == TestQuery.ToLower()) + .Select(tag => tag.Name) + .ToList() + }, + }; + + /// + /// Tests the lack of results using a query known to not exist in the test data, checking if the tag count is equal to + /// zero. + /// + /// A representing the asynchronous test. + [Test] + public void TagDatabase_SearchForTags_NoResults() + { + // Arrange + // ReSharper disable once StringLiteralTypo - intentionally using a typo'd version of Ambient. + const string query = "ambionte"; + + // Act + List tags = this.Database.Search(query, TagDatabaseSearchMode.Fuzzy); + List tagsWithAliases = this.Database.SearchWithAliases(query, TagDatabaseSearchMode.Fuzzy); + // Assert + Assert.That(tags.Count, Is.EqualTo(0)); + Assert.That(tagsWithAliases.Count, Is.EqualTo(0)); + } + + /// + /// Tests that a search can be made and data can be retrieved with a name and query with diacritics. + /// + /// The query to test. + /// The search mode to use, should be selected using the enum. + /// A representing the asynchronous test. + [Test] + [TestCase("áéíóúçýỷủ", TagDatabaseSearchMode.Fuzzy, TestName = "TagDatabase_SearchForTags_Normalised_Fuzzy")] + [TestCase("Tag test áéíóúç", TagDatabaseSearchMode.StartsWith, + TestName = "TagDatabase_SearchForTags_Normalised_StartsWith")] + [TestCase("áéíóúçýỷủ", TagDatabaseSearchMode.EndsWith, TestName = "TagDatabase_SearchForTags_Normalised_EndsWith")] + [TestCase("Tag test áéíóúçýỷủ", TagDatabaseSearchMode.ExactMatch, + TestName = "TagDatabase_SearchForTags_Normalised_ExactMatch")] + [TestCase("áéíóúçýỷủ", TagDatabaseSearchMode.ExactMatch, + TestName = "TagDatabase_SearchForTags_Normalised_ExactMatch")] + public async Task TagDatabase_SearchForTags_NormalisedDiacritics(string query, TagDatabaseSearchMode mode) + { + // Arrange + Tag normalisedTest = new() + { + Name = "Tag test áéíóúçýỷủ", + IsTopLevel = false, + TagBindings = ["genre", "style"], + Parents = ["Ambient", "Electronic"], + Aliases = ["áéíóúçýỷủ"], + }; + await this.Database.WriteTagToDatabase(normalisedTest); + + // Act + List tags = this.Database.SearchWithAliases(query, mode); + List tagNames = tags.Select(tag => tag.Name).ToList(); + + // Assert + Assert.That(tags.Count, Is.EqualTo(1)); + Assert.That( + tagNames.All(name => name.Contains(query, StringComparison.OrdinalIgnoreCase)), + $"This tag did not contain the search query `{query}`"); + } + + /// + /// Tests whether the search functionality is functioning correctly using "ambient" as the query. + /// + /// The search mode to use, should be selected using the enum. + /// + /// The key in the that stores the List compare the + /// retrieved tag names to. + /// + /// A representing the asynchronous test. + [Test] + + // ambient, dark ambient, tribal ambient, ritual ambient, space ambient + [TestCase(TagDatabaseSearchMode.Fuzzy, "NoAKAsFuzzy", + TestName = "TagDatabase_SearchForTags_WithResults_NoAKAsFuzzy")] + + // ambient + [TestCase(TagDatabaseSearchMode.StartsWith, "NoAKAsStartsWith", + TestName = "TagDatabase_SearchForTags_WithResults_NoAKAsStartsWith")] + + // ambient, dark ambient, tribal ambient, ritual ambient, space ambient + [TestCase(TagDatabaseSearchMode.EndsWith, "NoAKAsEndsWith", + TestName = "TagDatabase_SearchForTags_WithResults_NoAKAsEndsWith")] + + // ambient + [TestCase(TagDatabaseSearchMode.ExactMatch, "NoAKAsExactMatch", + TestName = "TagDatabase_SearchForTags_WithResults_NoAKAsExactMatch")] + public void TagDatabase_SearchForTags_SearchResultsNoAlias(TagDatabaseSearchMode mode, string expectedResultKey) + { + List retrievedTags = this.Database.Search(TestQuery, mode); + + List retrievedTagNames = retrievedTags.Select(tag => tag.Name).ToList(); + + Assert.That(retrievedTagNames, Is.EquivalentTo(this.expectedSearchResults[expectedResultKey])); + } + + + // ambient, dark ambient (AMBIENT industrial), tribal ambient, ritual ambient, space ambient + [TestCase(TagDatabaseSearchMode.Fuzzy, "WithAKAsFuzzy", + TestName = "TagDatabase_SearchForTags_WithResults_WithAKAsFuzzy")] + + // ambient, ambient, dark ambient (AMBIENT industrial) + [TestCase(TagDatabaseSearchMode.StartsWith, "WithAKAsStartsWith", + TestName = "TagDatabase_SearchForTags_WithResults_WithAKAsStartsWith")] + + // ambient, dark ambient, tribal ambient (ethnic AMBIENT), ritual ambient (ritual dark AMBIENT, dark ritual AMBIENT), space ambient + [TestCase(TagDatabaseSearchMode.EndsWith, "WithAKAsEndsWith", + TestName = "TagDatabase_SearchForTags_WithResults_WithAKAsEndsWith")] + + // ambient + [TestCase(TagDatabaseSearchMode.ExactMatch, "WithAKAsExactMatch", + TestName = "TagDatabase_SearchForTags_WithResults_WithAKAsExactMatch")] + public void TagDatabase_SearchForTags_SearchResultsWithAlias(TagDatabaseSearchMode mode, string expectedResultKey) + { + List retrievedTags = this.Database.SearchWithAliases(TestQuery, mode); + + List retrievedTagNames = retrievedTags.Select(tag => tag.Name).ToList(); + + Assert.That(retrievedTagNames, Is.EquivalentTo(this.expectedSearchResults[expectedResultKey])); + } +} \ No newline at end of file diff --git a/Tests/TagDatabaseTagRetrievalTests.cs b/Tests/TagDatabaseTagRetrievalTests.cs new file mode 100644 index 0000000..509dcaa --- /dev/null +++ b/Tests/TagDatabaseTagRetrievalTests.cs @@ -0,0 +1,125 @@ +using NUnit.Framework; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to the retrieval of tag from a . +/// +[TestFixture] +public class TagDatabaseTagRetrievalTests : TestBase +{ + private static IEnumerable SelectTagTestCases + { + get + { + yield return new TestCaseData(TestSampleTags.Ambient); + yield return new TestCaseData(TestSampleTags.SpaceAmbient); + yield return new TestCaseData(TestSampleTags.RitualAmbient); + } + } + + /// + /// Tests if retrieving all tags from the test matches + /// . + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabase_GetAllTags_AllTags() + { + // Arrange + List expectedNames = TestSampleTags.AllTags() + .Select(tag => tag.Name) + .ToList(); + + // Act + List tags = await this.Database.GetAllTagsFromDatabase(); + List retrievedNames = tags.Select(tag => tag.Name).ToList(); + + // Assert + Assert.That(tags.Count, Is.EqualTo(expectedNames.Count)); + Assert.That(retrievedNames, Is.EquivalentTo(expectedNames)); + } + + /// + /// Tests if retrieving all top-level tags from the test matches + /// (filtered only tags where IsTopLevel is true) + /// + /// A representing the asynchronous test. + [Test] + public async Task TagDatabase_GetAllTags_AllTopLevelTags() + { + // Arrange + List expectedNames = TestSampleTags.AllTags() + .Where(tag => tag.IsTopLevel) + .Select(tag => tag.Name) + .ToList(); + + // Act + List tags = await this.Database.GetAllTagsFromDatabase(true); + List retrievedNames = tags.Select(tag => tag.Name).ToList(); + + // Assert + Assert.That(tags.Count, Is.EqualTo(expectedNames.Count)); + Assert.That(retrievedNames, Is.EquivalentTo(expectedNames)); + } + + /// + /// Tests if GetTagChildren retrieves all expected tags. + /// + /// A representing the asynchronous test. + [Test] + public void TagDatabase_GetTagChildren() + { + // Arrange + string tagNameToQuery = TestSampleTags.Ambient.Name; + List expectedNames = + [ + TestSampleTags.DarkAmbient.Name, + TestSampleTags.TribalAmbient.Name, + TestSampleTags.SpaceAmbient.Name, + ]; + + // Act + List tags = this.Database.GetTagChildren(tagNameToQuery); + List retrievedNames = tags.Select(tag => tag.Name).ToList(); + + // Assert + Assert.That(tags.Count, Is.EqualTo(expectedNames.Count)); + Assert.That(retrievedNames, Is.EquivalentTo(expectedNames)); + } + + /// + /// Tests if SelectTag is selecting tags correctly. + /// + /// The tag to select. + /// A representing the asynchronous test. + [Test] + [TestCaseSource(nameof(SelectTagTestCases))] + public async Task TagDatabase_SelectTag(Tag inputTag) + { + // Act + Tag? selectedTag = await this.Database.SelectTagFromDatabase(inputTag.Name); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(selectedTag!.Id, Is.Not.Null); + Assert.That(selectedTag.ParentIds.Count, Is.EqualTo(inputTag.Parents.Count)); + Assert.That(selectedTag.Parents.Count, Is.EqualTo(inputTag.Parents.Count)); + Assert.That(selectedTag.Parents, Is.EqualTo(inputTag.Parents)); + Assert.That(selectedTag.Aliases.Count, Is.EqualTo(inputTag.Aliases.Count)); + Assert.That(selectedTag.Aliases, Is.EquivalentTo(inputTag.Aliases)); + } + } + + /// + /// Tests if selecting a non-existent tag results in null. + /// + [Test] + public void TagDatabase_SelectTag_ReturnNullOnTagNotFound() + { + const string nonexistentTagName = "This tag does not exist"; + Assert.ThatAsync(async () => await this.Database.SelectTagFromDatabase(nonexistentTagName), Is.Null); + } +} \ No newline at end of file diff --git a/Tests/TagHierarchyManager.Tests.csproj b/Tests/TagHierarchyManager.Tests.csproj new file mode 100644 index 0000000..d06b3ae --- /dev/null +++ b/Tests/TagHierarchyManager.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + + + + + + + + + + diff --git a/Tests/TagObjectTests.cs b/Tests/TagObjectTests.cs new file mode 100644 index 0000000..a9326c3 --- /dev/null +++ b/Tests/TagObjectTests.cs @@ -0,0 +1,156 @@ +using NUnit.Framework; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// Tests relating to the object. +/// +public class TagObjectTests : TestBase +{ + private static IEnumerable ValidTagEntryTestCases + { + get + { + yield return new TestCaseData( // ambient - top level tag, no parents + "Ambient", // name + true, // is top level + ExpectedTagBindings, // tag bindings + new List(), // parent names + string.Empty, // notes + new List()) // aliases + .SetName("TagEntry_NewTag_TopLevelNoParents"); + + yield return new TestCaseData( // country - top level tag, with parents + "Country", + true, + ExpectedTagBindings, + new List { "Northern American Music" }, + "asdfghjkl", + new List { "Country and Western" }) + .SetName("TagEntry_NewTag_TopLevelWIthParents"); + + yield return new TestCaseData( // ambient americana - not top level, with parents + "Ambient Americana", + false, + ExpectedTagBindings, + new List { "Ambient", "Northern American Music" }, + "asdfghjkl", + new List { "Ambient Country" }) + .SetName("TagEntry_NewTag_NotTopLevelNoParents"); + } + } + + /// + /// Tests whether changing the top level allows a with a parent can be validated successfully. + /// + /// The boolean value to change the test tag to. + [Test] + [TestCase(true)] + [TestCase(false)] + public void TagEntry_ChangeIsTopLevel_Validated(bool value) + { + // Arrange + Tag testTag = new() + { + Name = "Ritual Ambient", + Parents = ["Ambient"], + IsTopLevel = false, + }; + + // Act + testTag.IsTopLevel = value; + + // Assert + Assert.That(testTag.Validate(), Is.EqualTo(true)); + } + + /// + /// Tests if a object can be created and validated. + /// + /// The name of the tag. + /// Whether the tag is top level. + /// The tag bindings associated with the tag. + /// The names of the parents associated with the tag. + /// The notes associated with the tag. + /// The aliases/A.K.A.s to associate with the tag. + [Test] + [TestCaseSource(nameof(ValidTagEntryTestCases))] + public void TagEntry_NewTag_Validated( + string name, bool isTopLevel, List tagBindings, List parentNames, string notes, + List aliases) + { + // Arrange + Tag testTag = new() + { + Name = name, + IsTopLevel = isTopLevel, + TagBindings = tagBindings, + Parents = parentNames, + Notes = notes, + Aliases = aliases, + }; + + // Act + bool tagValidated = testTag.Validate(); + + // Assert + Assert.That(tagValidated, Is.EqualTo(true)); + } + + /// + /// Tests if an when attempting to validate an orphan (no + /// parents). + /// + [Test] + public void TagEntry_OrphanTag_ThrowInvalidOperationExceptionOnOrphanTagCreation() + { + // Arrange + Tag invalidTag = new() + { + Name = "Orphan Tag Test", + IsTopLevel = false, + }; + + // Act/Assert + Assert.Throws(() => invalidTag.Validate()); + } + + /// + /// Tests if an is thrown when attempting to validate an orphan + /// with an empty list. + /// + [Test] + public void TagEntry_OrphanTag_ThrowInvalidOperationExceptionOnOrphanTagWithEmptyList() + { + // Arrange + Tag topLevelTag = new() + { + Name = "Ritual Ambient", + Parents = [], + IsTopLevel = false, + }; + + // Assert + Assert.Throws(() => topLevelTag.Validate()); + } + + /// + /// Tests whether an is thrown when attempting to validate a tag whose + /// ParentNames list contains itself. + /// + [Test] + public void TagEntry_SelfParentTag_ThrowInvalidOperationExceptionOnSelfParentAttempt() + { + // Arrange + Tag topLevelTag = new() + { + Name = "Ambient", + Parents = ["Ambient"], + IsTopLevel = false, + }; + + // Act/Assert + Assert.Throws(() => topLevelTag.Validate()); + } +} \ No newline at end of file diff --git a/Tests/TestBase.cs b/Tests/TestBase.cs new file mode 100644 index 0000000..52436fe --- /dev/null +++ b/Tests/TestBase.cs @@ -0,0 +1,207 @@ +using NUnit.Framework; +using Serilog; +using Serilog.Sinks.SystemConsole.Themes; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.Tests; + +/// +/// The base class for every test for the application. +/// +public abstract class TestBase +{ + /// + /// The expected version value in the database. + /// + public const int ExpectedVersion = 1; + + /// + /// The expected default tag binding key in the database. + /// + protected const string ExpectedTagBindKey = "default_tag_bind"; + + /// + /// The expected version key in the database. + /// + protected const string ExpectedVersionKey = "version"; + + protected static readonly List ExpectedTagBindings = ["genre", "style"]; + + /// + /// Gets the database to set up for the tests. + /// + protected readonly TagDatabase Database = new(); + + /// + /// A logger that writes to the console, using the Serilog library. + /// + private static readonly ILogger Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console(theme: AnsiConsoleTheme.Code, applyThemeToRedirectedOutput: true) + .CreateLogger(); + + /// + /// Adds sample data to the database, with exceptions for tests that do not/cannot use the test data. + /// + /// A representing the asynchronous operation. + [SetUp] + public async Task PopulateSampleData() + { + // classes and tests that don't interface with the sample data + // and therefore do not need this to execute. + HashSet ignoredClasses = + [ + nameof(TagDatabaseInitTests), + nameof(SettingsTests), + nameof(TagObjectTests), + nameof(TagDatabaseWriteTests), + nameof(ImporterTests), + ]; + + string? className = TestContext.CurrentContext.Test.ClassName; + Logger.Debug("[TestBase.PopulateSampleData] Current class name: {ClassName}", className); + if (className == null || ignoredClasses.Contains(className.Replace("TagHierarchyManager.Tests.", string.Empty))) + { + Logger.Debug("[TestBase.PopulateSampleData] Ignoring as class name {ClassName} in ignoredClasses", className); + return; + } + + List sampleTags = TestSampleTags.AllTags(); + this.Database.ClearTags(); + foreach (Tag inputTag in sampleTags) await this.Database.WriteTagToDatabase(inputTag); + } + + // do not use in Test1_Init. That needs to be tested at a lower level. + + /// + /// Creates the in memory and configures the DefaultTagBindings to the expected ones for the + /// tests. + /// + /// A representing the asynchronous operation. + [OneTimeSetUp] + public async Task SetUpDatabaseForTesting() + { + await this.Database.CreateAsync(":memory:"); + this.Database.DefaultTagBindings = ExpectedTagBindings; + } + + [OneTimeTearDown] + private void ExitDatabase() + { + this.Database.Close(); + } + + // resharper disable MemberCanBePrivate.Global + /// + /// Stores the sample tag data. + /// + protected static class TestSampleTags + { + /// + /// Gets the Ambient test tag. + /// + public static Tag Ambient => new() + { + Name = "Ambient", + IsTopLevel = true, + TagBindings = ExpectedTagBindings, + }; + + /// + /// Gets the Dark Ambient test tag (parents: Ambient, Post-Industrial). + /// + public static Tag DarkAmbient => new() + { + Name = "Dark Ambient", + IsTopLevel = false, + TagBindings = ExpectedTagBindings, + Parents = ["Ambient", "Post-Industrial"], + Aliases = ["Ambient Industrial"], + }; + + /// + /// Gets the Electronic test tag. + /// + public static Tag Electronic => new() + { + Name = "Electronic", + IsTopLevel = true, + TagBindings = ExpectedTagBindings, + }; + + /// + /// Gets the Industrial & Noise test tag. + /// + public static Tag IndustrialAndNoise => new() + { + Name = "Industrial & Noise", + IsTopLevel = true, + TagBindings = ExpectedTagBindings, + }; + + /// + /// Gets the Post-Industrial test tag (parents: Industrial & Noise). + /// + public static Tag PostIndustrial => new() + { + Name = "Post-Industrial", + IsTopLevel = false, + TagBindings = ExpectedTagBindings, + Parents = ["Industrial & Noise"], + }; + + /// + /// Gets the Ritual Ambient test tag (parents: Dark Ambient). + /// + public static Tag RitualAmbient => new() + { + Name = "Ritual Ambient", + IsTopLevel = false, + TagBindings = ExpectedTagBindings, + Parents = ["Dark Ambient"], + Aliases = ["Ritual Dark Ambient", "Dark Ritual Ambient"], + }; + + /// + /// Gets the Space Ambient test tag (parents: Ambient, Electronic). + /// + public static Tag SpaceAmbient => new() + { + Name = "Space Ambient", + IsTopLevel = false, + TagBindings = ExpectedTagBindings, + Parents = ["Ambient", "Electronic"], + }; + + /// + /// Gets the Tribal Ambient test tag (parents: Tribal Ambient). + /// + public static Tag TribalAmbient => new() + { + Name = "Tribal Ambient", + IsTopLevel = false, + TagBindings = ExpectedTagBindings, + Parents = ["Ambient"], + Aliases = ["Ethnic Ambient", "Ethno Ambient"], + }; + + /// + /// Gets all tags in the SampleTags class. + /// + /// a of s with the sample tag data. + public static List AllTags() + { + return + [ + Ambient, + Electronic, + IndustrialAndNoise, + PostIndustrial, + DarkAmbient, + RitualAmbient, + SpaceAmbient, + TribalAmbient, + ]; + } + } +} \ No newline at end of file diff --git a/UI/Terminal/Program.cs b/UI/Terminal/Program.cs new file mode 100644 index 0000000..653e9b3 --- /dev/null +++ b/UI/Terminal/Program.cs @@ -0,0 +1,55 @@ +using Serilog; +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Views; +using Terminal.Gui.App; + +namespace TagHierarchyManager.UI.TerminalUI; + +/// +/// The class that the application will execute at first. +/// +internal static class Program +{ + /// + /// A logger for debugging purposes that logs to any compatible .NET debugger, using Serilog. + /// + public static readonly ILogger Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Debug() + .CreateLogger(); + + /// + /// Gets the top-level view, which also encapsulates the main window of the application. + /// + internal static readonly TopView TopView = new(); + + /// + /// Gets the current database associated with the running process of the application. + /// + internal static TagDatabase? CurrentDatabase = null!; + + /// + /// Gets the main editor window of the application, essentially acting as a shortcut. + /// + internal static EditorWindow MainView => TopView.Window; + + /// + /// The method the .NET runtime will use to execute the application. + /// + public static void Main() + { + try + { + Application.Init(); + Application.Run(TopView); + } + catch (Exception ex) + { + Logger.Fatal(ex, "FATAL ERROR WAS CAUGHT."); + } + finally + { + Application.Shutdown(); + } + } +} \ No newline at end of file diff --git a/UI/Terminal/Properties/PublishProfiles/Linux.pubxml b/UI/Terminal/Properties/PublishProfiles/Linux.pubxml new file mode 100644 index 0000000..6bb4f1b --- /dev/null +++ b/UI/Terminal/Properties/PublishProfiles/Linux.pubxml @@ -0,0 +1,12 @@ + + + + linux-x64 + true + true + full + true + none + false + + diff --git a/UI/Terminal/Properties/PublishProfiles/Windows.pubxml b/UI/Terminal/Properties/PublishProfiles/Windows.pubxml new file mode 100644 index 0000000..4e8b01c --- /dev/null +++ b/UI/Terminal/Properties/PublishProfiles/Windows.pubxml @@ -0,0 +1,12 @@ + + + + win-x64 + true + true + full + true + none + false + + diff --git a/UI/Terminal/Services/TagDatabaseService.cs b/UI/Terminal/Services/TagDatabaseService.cs new file mode 100644 index 0000000..4796970 --- /dev/null +++ b/UI/Terminal/Services/TagDatabaseService.cs @@ -0,0 +1,266 @@ +using TagHierarchyManager.Common; +using TagHierarchyManager.Exporters; +using TagHierarchyManager.Importers; +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Views; +using Terminal.Gui.App; + +namespace TagHierarchyManager.UI.TerminalUI.Services; + +/// +/// Handles interaction between the and the UI. +/// +public static class TagDatabaseService +{ + /// + /// An event thrown when the database initialisation has started. + /// + public static event EventHandler DatabaseInitialising = delegate { }; + + /// + /// An event thrown when the database export process has completed, storing a string with it pointing to the + /// export's file path. + /// + public static event EventHandler ExportCompleted = delegate { }; + + /// + /// Thrown when an exception has occurred while exporting, storing a string with it + /// specifying the error. + /// + public static event EventHandler ExportError = delegate { }; + + /// + /// Thrown when the database export process has started. + /// + public static event EventHandler ExportStarted = delegate { }; + + /// + /// Thrown when an exception has occurred while processing data to import, storing a string with it + /// specifying the error. + /// + public static event EventHandler ImportError = delegate { }; + + /// + /// Thrown when the initialisation has completed. + /// + public static event EventHandler InitialisationComplete = delegate { }; + + /// + /// Thrown when an exception has occurred while attempting to initialise the database, storing a string with it + /// specifying the error. + /// + public static event EventHandler InitialisationError = delegate { }; + + /// + /// Thrown when the search process has finished, storing with it the number of Tags found. + /// + public static event EventHandler> SearchFinished = delegate { }; + + /// + /// Thrown when a new Tag has been added, storing the newly created Tag object with it. + /// + public static event EventHandler TagAdded = delegate { }; + + /// + /// Thrown when a Tag has been deleted, storing the ID and name of the Tag with it. + /// + public static event EventHandler<(int, string)> TagDeleted = delegate { }; + + /// + /// Thrown when a Tag has been saved, regardless of whether it was new or not, storing the saved Tag object + /// with it. + /// + public static event EventHandler TagSaved = delegate { }; + + // should this be generic and inside the Core? + // not yet, we'll see when i get to adding the avalonia ui. + + /// + /// Throws a save dialog to the user, and starts the initialisation process. + /// + public static void CreateDatabase((string filePath, bool userAllowedOverwrite) newDatabase) + { + StartDatabaseInit(newDatabase.filePath, newDatabase.userAllowedOverwrite); + } + + public static async Task DeleteTag(Tag tag) + { + string tagName = tag.Name; + int tagId = tag.Id; + if (Program.CurrentDatabase != null) await Program.CurrentDatabase.DeleteTag(tag.Id); + TagDeleted.Invoke(null, (tagId, tagName)); + + } + + /// + /// Throws a load dialog to the user, and starts the initialisation process. + /// + public static void PromptDatabaseLoad() + { + string? newDatabasePath = StandardDialogs.DatabaseOpenDialog(); + if (newDatabasePath is null) return; + + StartDatabaseInit(newDatabasePath); + } + + public static void SearchTags(string query, TagDatabaseSearchMode mode, bool searchAliases) + { + if (Program.CurrentDatabase is null) throw new InvalidOperationException(ErrorMessages.DbNotInitialised); + + List tags = searchAliases + ? Program.CurrentDatabase.SearchWithAliases(query, mode) + : Program.CurrentDatabase.Search(query, mode); + SearchFinished.Invoke(Program.CurrentDatabase, tags.OrderBy(t => t.Name).ToList()); + } + + /// + /// Starts the database initialisation. + /// + /// The file path of the database to create or load. + /// + /// Whether to overwrite the database file on filePath.
+ /// Null implies database load - true/false implies database creation. + /// + /// Optional path to a file to import into the new database (used on creation). + public static void StartDatabaseInit( + string filePath, + bool? overwriteOnCreation = null, + string? importedFilePath = null) + { + ClearMainWindowData(); + + TagDatabase db = new(); + SubscribeToEvents(db); + + _ = Task.Run(async () => + { + try + { + Dictionary? tagsToImport = null; + if (overwriteOnCreation.HasValue) + { + if (!string.IsNullOrEmpty(importedFilePath)) + try + { + tagsToImport = await PickImporterFromFileExt(importedFilePath) + .ImportFromFileAsync(importedFilePath); + } + catch (Exception ex) + { + Application.Invoke(() => ImportError.Invoke(null, ex.Message)); + return; + } + + DatabaseInitialising.Invoke(null, EventArgs.Empty); + await db.CreateAsync(filePath, overwriteOnCreation.Value, + tagsToImport); + } + else + { + DatabaseInitialising.Invoke(null, EventArgs.Empty); + await db.LoadAsync(filePath); + } + } + catch (Exception ex) + { + InitialisationError.Invoke(db, ex.Message); + UnsubscribeFromEvents(db); + } + }); + } + + public static void StartExportProcess() + { + if (Program.CurrentDatabase is null) return; + (string filePath, bool userAllowedOverwrite)? saveLocation = StandardDialogs.ExportSaveDialog(); + if (saveLocation is null) return; + _ = Task.Run(async () => + { + ExportStarted.Invoke(null, EventArgs.Empty); + await ExportToFile(saveLocation.Value); + }); + } + + public static async Task WriteTagToDatabase(Tag tag) + { + bool newTag = tag.Id == 0; + if (Program.CurrentDatabase != null) await Program.CurrentDatabase.WriteTagToDatabase(tag); + + if (newTag) TagAdded.Invoke(Program.CurrentDatabase, tag); + TagSaved.Invoke(Program.CurrentDatabase, tag); + } + + /// + /// Clears the data associated with the main window, prompting the user if unsaved changes were detected. + /// + /// True if the data has been cleared. + private static void ClearMainWindowData() + { + if (Program.CurrentDatabase is null) return; + UnsubscribeFromEvents(Program.CurrentDatabase); + Program.CurrentDatabase.Close(); + } + + private static async Task ExportToFile((string filePath, bool userAllowedOverwrite) exportLocation) + { + if (Program.CurrentDatabase is null) return; + IExporter exporter = PickExporterFromFileExt(exportLocation.filePath); + try + { + string export = exporter.ExportDatabase(Program.CurrentDatabase); + if (exportLocation.userAllowedOverwrite && File.Exists(exportLocation.filePath)) + File.Delete(exportLocation.filePath); + await File.WriteAllTextAsync(exportLocation.filePath, export); + ExportCompleted.Invoke(null, exportLocation.filePath); + } + catch (Exception ex) + { + ExportError.Invoke(exportLocation.filePath, ex.Message); + } + } + + private static void OnInitialisationComplete(object? sender, EventArgs e) + { + Application.Invoke(() => + { + Program.Logger.Debug("[LoadingDialog.OnInitialised] Event has been caught"); + Program.Logger.Information("[LoadingDialog.OnInitialised] Sender: {@Sender}", sender); + if (sender is not TagDatabase db) return; + + Program.CurrentDatabase = db; + + InitialisationComplete.Invoke(db, EventArgs.Empty); + }); + } + + private static IExporter PickExporterFromFileExt(string path) + { + string fileExt = Path.GetExtension(path); + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (fileExt == FileTypes.MusicBeeTagHierarchyTemplate.FileExtension) return new MusicBeeTagHierarchyExporter(); + + throw new NotSupportedException($"File extension '{fileExt}' is not supported."); + } + + private static Importer PickImporterFromFileExt(string path) + { + string fileExt = Path.GetExtension(path); + + // if more importers are added, convert this to a switch statement based on file extension. + // ReSharper disable once ConvertIfStatementToReturnStatement + if (fileExt == FileTypes.MusicBeeTagHierarchyTemplate.FileExtension) return new MusicBeeTagHierarchyImporter(); + + throw new NotSupportedException($"File extension '{fileExt}' is not supported."); + } + + private static void SubscribeToEvents(TagDatabase db) + { + db.InitialisationComplete += OnInitialisationComplete; + } + + private static void UnsubscribeFromEvents(TagDatabase db) + { + db.InitialisationComplete -= OnInitialisationComplete; + } +} \ No newline at end of file diff --git a/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj b/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj new file mode 100644 index 0000000..a51b83c --- /dev/null +++ b/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + enable + enable + portable + TagHierarchyManager.UI.TerminalUI + + + + + + + + + + + + + + + + diff --git a/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj.DotSettings b/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj.DotSettings new file mode 100644 index 0000000..dcf9c9b --- /dev/null +++ b/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj.DotSettings @@ -0,0 +1,7 @@ + + True + True \ No newline at end of file diff --git a/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs b/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs new file mode 100644 index 0000000..7876294 --- /dev/null +++ b/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs @@ -0,0 +1,63 @@ +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A for handling TagDatabase settings. +/// +internal class DbSettingsDialog : UserInputDialog +{ + private readonly TextField defaultTagBindingField = new() + { + X = 0, + Width = Dim.Fill(), + }; + + /// + /// Initializes a new instance of the class. + /// + public DbSettingsDialog() + { + this.InitialiseUI(); + } + + private void InitialiseUI() + { + this.Title = $"Settings - {Program.CurrentDatabase!.Name}"; + this.Width = 70; + this.Height = 12; + + Label schemaVersionLabel = new() + { + X = 0, + Y = 0, + Title = $"Database version: {Program.CurrentDatabase.Version}", + }; + + Label defaultTagBindingLabel = new() + { + X = 0, + Y = Pos.Bottom(schemaVersionLabel) + 1, + Title = "Default tag binding (separated by semi-colon):", + }; + this.defaultTagBindingField.Y = Pos.Bottom(defaultTagBindingLabel); + this.defaultTagBindingField.Text = string.Join(';', Program.CurrentDatabase.DefaultTagBindings); + this.OkButton.Accepting += this.OkButton_OnAccepting; + + this.Add(schemaVersionLabel, defaultTagBindingLabel, this.defaultTagBindingField); + } + + private void OkButton_OnAccepting(object? sender, EventArgs e) + { + string newDefaultTagBindings = this.defaultTagBindingField.Text.Replace("::", string.Empty); + Program.CurrentDatabase!.DefaultTagBindings = + newDefaultTagBindings.Split( + ';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + Program.Logger.Debug("{@TagBindings}", Program.CurrentDatabase.DefaultTagBindings); + Application.RequestStop(); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/Dialogs/ImportDialog.cs b/UI/Terminal/Views/Dialogs/ImportDialog.cs new file mode 100644 index 0000000..a840ebf --- /dev/null +++ b/UI/Terminal/Views/Dialogs/ImportDialog.cs @@ -0,0 +1,234 @@ +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A for importing MusicBee tag hierarchy files. +/// +internal class ImportDialog : UserInputDialog +{ + private (string filePath, bool userAllowedOverwrite)? chosenDatabaseLocation; + private Label errorLabel = null!; + private View importingView = null!; + + /// + /// Initializes a new instance of the class. + /// + public ImportDialog() + { + this.InitialiseUI(); + } + + /// + /// Gets the object representing the current database to save to. + /// + internal (string filePath, bool userAllowedOverwrite)? CurrentDatabase => this.chosenDatabaseLocation; + + /// + /// Gets the containing the file path to where the new should be + /// saved. + /// + private TextField DatabaseField { get; set; } = null!; + + /// + /// Gets the containing the file path to the target file for import. + /// + private TextField ImportedFileField { get; set; } = null!; + + private void databaseBrowseButton_OnAccepting(object? sender, CommandEventArgs e) + { + (string filePath, bool userAllowedOverwrite)? dbFile = StandardDialogs.NewDatabaseSaveDialog(); + if (dbFile.HasValue) + { + this.chosenDatabaseLocation = dbFile; + this.DatabaseField.Text = dbFile.Value.filePath; + this.DatabaseField.CursorPosition = dbFile.Value.filePath.Length; + + if (!string.IsNullOrEmpty(this.ImportedFileField.Text)) this.OkButton.Enabled = true; + } + + e.Handled = true; + } + + private void InitialiseUI() + { + this.Title = "Import file"; + this.Arrangement = ViewArrangement.Movable; + this.Width = 70; + this.Height = 12; + + Label tagHierarchyLabel = new() + { + X = 0, + Y = 0, + Title = "File to import:", + }; + + const string browseTitle = "Browse"; + + this.ImportedFileField = new TextField + { + Y = Pos.Bottom(tagHierarchyLabel), + Width = Dim.Fill() ! - (browseTitle.Length + 5), + CanFocus = false, + CursorVisibility = CursorVisibility.Invisible, + }; + + Button importFileBrowseButton = new() + { + X = Pos.Right(this.ImportedFileField), + Y = Pos.Bottom(tagHierarchyLabel), + Width = Dim.Fill(), + Title = browseTitle, + }; + importFileBrowseButton.Accepting += this.OnImportFileBrowseClick; + + Label databaseLabel = new() + { + X = 0, + Y = Pos.Bottom(this.ImportedFileField) + 1, + Title = "Database to save to:", + }; + + this.DatabaseField = new TextField + { + Y = Pos.Bottom(databaseLabel), + Width = Dim.Fill() ! - (browseTitle.Length + 5), + CanFocus = false, + CursorVisibility = CursorVisibility.Invisible, + }; + + Button databaseBrowseButton = new() + { + X = Pos.Right(this.DatabaseField), + Y = Pos.Bottom(databaseLabel), + Width = Dim.Fill(), + Title = browseTitle, + }; + databaseBrowseButton.Accepting += this.databaseBrowseButton_OnAccepting; + + this.importingView = new View + { + X = 0, + Y = Pos.Bottom(this.DatabaseField) + 1, + Width = Dim.Fill(), + Height = 1, + }; + + this.errorLabel = new Label + { + X = 0, + Y = Pos.Bottom(this.DatabaseField) + 1, + Width = Dim.Fill(), + Visible = false, + Text = "Import failed.", + }; + + SpinnerView spinner = new() + { + AutoSpin = true, + Style = new SpinnerStyle.Dots(), + X = 0, + }; + + Label importingLabel = new() + { + Title = "Importing...", + Width = Dim.Auto(), + X = Pos.Right(spinner) + 1, + }; + + this.OkButton.Accepting += this.OkButton_OnAccepting; + this.OkButton.Enabled = false; + + this.Add(tagHierarchyLabel, this.ImportedFileField, importFileBrowseButton, databaseLabel, + this.DatabaseField, databaseBrowseButton, this.importingView, this.errorLabel); + this.importingView.Add(spinner, importingLabel); + this.importingView.Visible = false; + TagDatabaseService.ImportError += this.TagDatabaseService_OnImportError; + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialisationComplete; + this.Closing += this.OnClosing; + } + + private void OkButton_OnAccepting(object? sender, CommandEventArgs e) + { + if (this.chosenDatabaseLocation is null || + string.IsNullOrEmpty(this.ImportedFileField.Text)) + { + MessageBox.ErrorQuery( + "Error", + "One or more files were missing for the import process.\nPlease choose a valid file to import and/or a location for the tag database, and try again.", + "OK"); + } + else + { + foreach (Button button in this.Buttons) button.Enabled = false; + this.errorLabel.Visible = false; + this.importingView.Visible = true; + TagDatabaseService.StartDatabaseInit( + this.chosenDatabaseLocation.Value.filePath, + this.chosenDatabaseLocation.Value.userAllowedOverwrite, + this.ImportedFileField.Text); + } + + e.Handled = true; + } + + private void OnClosing(object? sender, ToplevelClosingEventArgs e) + { + if (this.importingView.Visible) + { + e.Cancel = true; + return; + } + + this.importingView.Visible = false; + this.errorLabel.Visible = false; + TagDatabaseService.ImportError -= this.TagDatabaseService_OnImportError; + TagDatabaseService.InitialisationComplete -= this.TagDatabaseService_OnInitialisationComplete; + + this.Closing -= this.OnClosing; + } + + private void OnImportFileBrowseClick(object? sender, CommandEventArgs e) + { + string? tagHierarchyFilePath = StandardDialogs.ImportOpenDialog(); + if (tagHierarchyFilePath is not null) + { + this.ImportedFileField.Text = tagHierarchyFilePath; + this.ImportedFileField.CursorPosition = tagHierarchyFilePath.Length; + + if (this.chosenDatabaseLocation is not null) this.OkButton.Enabled = true; + } + + e.Handled = true; + } + + private void TagDatabaseService_OnImportError(object? sender, string e) + { + Application.Invoke(() => + { + this.importingView.Visible = false; + this.errorLabel.Visible = true; + foreach (Button button in this.Buttons) button.Enabled = true; + MessageBox.ErrorQuery("Error", $"An error occurred trying to import the file:\n{e}", "OK"); + }); + } + + private void TagDatabaseService_OnInitialisationComplete(object? sender, EventArgs e) + { + Application.Invoke(() => + { + this.importingView.Visible = false; + this.errorLabel.Visible = false; + foreach (Button button in this.Buttons) button.Enabled = true; + this.RequestStop(); + }); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/Dialogs/UserInputDialog.cs b/UI/Terminal/Views/Dialogs/UserInputDialog.cs new file mode 100644 index 0000000..3009fe5 --- /dev/null +++ b/UI/Terminal/Views/Dialogs/UserInputDialog.cs @@ -0,0 +1,39 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A base class implementing a template centered around user-facing input. +/// +public class UserInputDialog : Dialog +{ + protected Button OkButton = null!; + private Button cancelButton = null!; + + /// + /// Initializes a new instance of the class. + /// + protected UserInputDialog() + { + this.InitialiseUI(); + } + + private void InitialiseUI() + { + this.OkButton = new Button + { + Title = "OK", + }; + + this.cancelButton = new Button + { + Title = "Cancel", + }; + + this.cancelButton.Accepting += (_, _) => Application.RequestStop(); + + this.AddButton(this.OkButton); + this.AddButton(this.cancelButton); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/EditorWindow.cs b/UI/Terminal/Views/EditorWindow/EditorWindow.cs new file mode 100644 index 0000000..6de7f78 --- /dev/null +++ b/UI/Terminal/Views/EditorWindow/EditorWindow.cs @@ -0,0 +1,136 @@ +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A comprising of a for browsing the tag hierarchy, and a +/// for editing tag data. +/// +// tbh this could be static, but i think i'm done doing this for now lol. +public class EditorWindow : Window +{ + internal ImportDialog? ImportDialog = null; + + /// + /// Initializes a new instance of the class. + /// + /// The top menu to base the window position on. + public EditorWindow(TopMenu topMenu) + { + this.BorderStyle = LineStyle.None; + this.Y = Pos.Bottom(topMenu); + + this.TagPane = new TagBrowserPane + { + X = 0, + Y = 0, + Width = Dim.Percent(20), + Height = Dim.Fill()! - 1, + CanFocus = true, + TabStop = TabBehavior.TabGroup, + }; + this.EditPane = new TagEditorPane + { + X = Pos.Right(this.TagPane), + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill()! - 1, + CanFocus = true, + TabStop = TabBehavior.TabGroup, + Enabled = false, + }; + this.StatusBar = new MultiModeStatusBar(); + + this.Add(this.TagPane, this.EditPane, this.StatusBar); + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_InitialisationComplete; + TagDatabaseService.DatabaseInitialising += this.TagDatabaseService_OnDatabaseInitialising; + TagDatabaseService.ImportError += this.TagDatabaseService_OnImportError; + TagDatabaseService.InitialisationError += this.TagDatabaseService_OnInitialisationError; + TagDatabaseService.ExportError += this.TagDatabaseService_OnExportError; + TagDatabaseService.ExportStarted += this.TagDatabaseService_OnExportStarted; + TagDatabaseService.ExportCompleted += this.TagDatabaseService_OnExportCompleted; + } + + /// + /// Gets the inside the window. + /// + internal TagEditorPane EditPane { get; } + + + /// + /// Gets the inside the window. + /// + internal TagBrowserPane TagPane { get; } + + private MultiModeStatusBar StatusBar { get; } + + private void TagDatabaseService_InitialisationComplete(object? sender, EventArgs e) + { + if (sender is not TagDatabase) return; + Application.Invoke(() => + { + this.Enabled = true; + this.EditPane.Enabled = false; + }); + } + + + private void TagDatabaseService_OnDatabaseInitialising(object? sender, EventArgs e) + { + Application.Invoke(() => { this.Enabled = false; }); + } + + private void TagDatabaseService_OnExportCompleted(object? sender, string e) + { + Application.Invoke(() => + { + this.Enabled = true; + this.EditPane.EnableIfTagLoaded(); + }); + } + + private void TagDatabaseService_OnExportError(object? sender, string e) + { + Application.Invoke(() => + { + MessageBox.ErrorQuery("Error", + $"An error occurred during the export process.\n\nError message:\n{e}", + "OK"); + this.Enabled = true; + this.EditPane.EnableIfTagLoaded(); + }); + } + + private void TagDatabaseService_OnExportStarted(object? sender, EventArgs e) + { + Application.Invoke(() => { this.Enabled = false; }); + } + + + private void TagDatabaseService_OnImportError(object? sender, string e) + { + Application.Invoke(() => + { + this.Enabled = true; + this.EditPane.Enabled = false; + }); + } + + private void TagDatabaseService_OnInitialisationError(object? sender, string errorMessage) + { + Application.Invoke(() => + { + MessageBox.ErrorQuery("Error", + $"An error has occurred trying to initialise the database.\n\nError message:\n{errorMessage}", + "OK"); + if (Program.CurrentDatabase == null) return; + this.Enabled = true; + this.EditPane.EnableIfTagLoaded(); + }); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/MultiModeStatusBar.cs b/UI/Terminal/Views/EditorWindow/MultiModeStatusBar.cs new file mode 100644 index 0000000..d88bee0 --- /dev/null +++ b/UI/Terminal/Views/EditorWindow/MultiModeStatusBar.cs @@ -0,0 +1,190 @@ +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +public class MultiModeStatusBar : View +{ + private TextOnly textOnlyView = null!; + private TextWithSpinner textWithSpinnerView = null!; + + public MultiModeStatusBar() + { + this.InitialiseUI(); + } + + private void InitialiseUI() + { + this.X = 0; + this.Y = Pos.AnchorEnd(1); + this.Width = Dim.Fill(); + this.Height = 1; + this.Visible = true; + this.CanFocus = false; + + this.textWithSpinnerView = new TextWithSpinner + { + Visible = false, + }; + + this.textOnlyView = new TextOnly + { + Visible = true, + }; + // Set the label text directly + this.textOnlyView.TextLabel.Text = "Ready."; + + this.Add(this.textWithSpinnerView, this.textOnlyView); + TagDatabaseService.InitialisationError += this.TagDatabaseService_OnInitialisationError; + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_InitialisationComplete; + TagDatabaseService.ImportError += this.TagDatabaseService_OnImportError; + TagDatabaseService.DatabaseInitialising += this.TagDatabaseService_OnDatabaseInitialising; + TagDatabaseService.TagSaved += this.TagDatabaseService_OnTagSaved; + TagDatabaseService.TagDeleted += this.TagDatabaseService_OnTagDeleted; + TagDatabaseService.ExportStarted += this.TagDatabaseService_OnExportStarted; + TagDatabaseService.ExportCompleted += this.TagDatabaseService_OnExportCompleted; + TagDatabaseService.ExportError += this.TagDatabaseService_OnExportError; + TagDatabaseService.SearchFinished += this.TagDatabaseService_OnSearchFinished; + } + + private void TagDatabaseService_InitialisationComplete(object? sender, EventArgs e) + { + this.UpdateStatusBarTextOnly("Database initialized successfully."); + } + + private void TagDatabaseService_OnDatabaseInitialising(object? sender, EventArgs e) + { + this.UpdateStatusBarWithSpinner("Initializing database..."); + } + + private void TagDatabaseService_OnExportCompleted(object? sender, string filePath) + { + this.UpdateStatusBarTextOnly($"Database exported successfully to \"{filePath}\"."); + } + + private void TagDatabaseService_OnExportError(object? sender, string e) + { + this.UpdateStatusBarTextOnly("Export failed."); + } + + private void TagDatabaseService_OnExportStarted(object? sender, EventArgs e) + { + this.UpdateStatusBarWithSpinner("Exporting database..."); + } + + private void TagDatabaseService_OnImportError(object? sender, string e) + { + this.UpdateStatusBarTextOnly("Error importing: " + e); + } + + private void TagDatabaseService_OnSearchFinished(object? sender, List result) + { + if (result.Count == 0) this.Clear(); + else this.UpdateStatusBarTextOnly(result.Count == 1 ? "One result found." : $"{result.Count} results found."); + } + + private void TagDatabaseService_OnInitialisationError(object? sender, string e) + { + this.UpdateStatusBarTextOnly("Error initializing database: " + e); + } + + private void TagDatabaseService_OnTagDeleted(object? sender, (int tagId, string tagName) tagData) + { + this.UpdateStatusBarTextOnly($"Tag \"{tagData.tagName}\" deleted successfully."); + } + + private void TagDatabaseService_OnTagSaved(object? sender, Tag tag) + { + this.UpdateStatusBarTextOnly($"Tag \"{tag.Name}\" saved successfully."); + } + + private void Clear() + { + Application.Invoke(() => + { + this.textWithSpinnerView.Visible = false; + this.textOnlyView.Visible = false; + }); + } + + private void UpdateStatusBarTextOnly(string message) + { + Application.Invoke(() => + { + this.textOnlyView.TextLabel.Text = message; + this.textWithSpinnerView.Visible = false; + this.textOnlyView.Visible = true; + }); + } + + private void UpdateStatusBarWithSpinner(string message) + { + Application.Invoke(() => + { + this.textWithSpinnerView.TextLabel.Text = message; + this.textWithSpinnerView.Visible = true; + this.textOnlyView.Visible = false; + }); + } + + private sealed class TextOnly : View + { + internal readonly Label TextLabel; + + public TextOnly() + { + this.X = 0; + this.Y = 0; + this.Width = Dim.Fill(); + this.Height = 1; + + this.TextLabel = new Label + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = 1, + Text = string.Empty, + }; + + this.Add(this.TextLabel); + } + } + + private sealed class TextWithSpinner : View + { + internal readonly Label TextLabel; + + public TextWithSpinner() + { + this.X = 0; + this.Y = 0; + this.Width = Dim.Fill(); + this.Height = 1; + + SpinnerView spinner = new() + { + X = 0, + Y = 0, + Width = 1, + Height = 1, + AutoSpin = true, + Style = new SpinnerStyle.Dots(), + }; + + this.TextLabel = new Label + { + X = Pos.Right(spinner) + 1, + Y = 0, + Width = Dim.Fill(), + Height = 1, + Text = string.Empty, + }; + + this.Add(spinner, this.TextLabel); + } + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/TagBrowserPane.cs b/UI/Terminal/Views/EditorWindow/TagBrowserPane.cs new file mode 100644 index 0000000..5f5e87e --- /dev/null +++ b/UI/Terminal/Views/EditorWindow/TagBrowserPane.cs @@ -0,0 +1,240 @@ +using System.Collections.ObjectModel; +using TagHierarchyManager.Common; +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A paned view for browsing the 's tag hierarchy tree. +/// +public class TagBrowserPane : View +{ + internal readonly Button NewTagButton = new(); + private ComboBox modeSelector = null!; + private TextField queryField = null!; + private ListView resultsList = null!; + private TreeView tagTree = null!; + private CheckBox searchAliasesCheckbox = null!; + private bool resultsListAutoSelect = true; + + /// + /// Initializes a new instance of the class. + /// + public TagBrowserPane() + { + this.InitialiseUI(); + } + + private void TagDatabaseService_OnSearchFinished(object? sender, List results) + { + this.resultsListAutoSelect = true; + if (results.Count > 0) + this.resultsList.SetSource(new ObservableCollection(results)); + else + this.resultsList.SetSource(["No results found."]); + this.resultsList.SelectedItem = 0; + this.resultsListAutoSelect = false; + } + + private void InitialiseUI() + { + TabView tabView = new() + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = true, + }; + + Tab tagTreeTab = new() + { + DisplayText = "Hierarchy Tree", + View = new View + { + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = true, + }, + }; + + this.tagTree = new TreeView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() ! - 2, + CanFocus = true, + MultiSelect = false, + }; + + this.NewTagButton.Y = Pos.Bottom(this.tagTree); + this.NewTagButton.Title = "New tag..."; + this.NewTagButton.Width = Dim.Fill(); + this.NewTagButton.CanFocus = true; + + tagTreeTab.View.Add(this.tagTree, this.NewTagButton); + + Tab searchTab = new() + { + DisplayText = "Search", + View = new View + { + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = true, + }, + }; + + this.queryField = new TextField + { + Width = Dim.Percent(75), + CanFocus = true, + Height = 1, + }; + this.modeSelector = new ComboBox + { + Y = Pos.Bottom(this.queryField) + 2, + Width = Dim.Fill(), + Height = 5, + CanFocus = true, + ReadOnly = true, + }; + this.searchAliasesCheckbox = new CheckBox + { + Y = Pos.Top(this.modeSelector) - 1, + Text = "Search in aliases", + CanFocus = true, + }; + Line separatorLine = new() + { + Y = Pos.Bottom(this.modeSelector), + }; + Button searchButton = new() + { + X = Pos.Right(this.queryField), + Y = 0, + Text = "Search", + Width = Dim.Fill(), + CanFocus = true, + }; + this.modeSelector.SetSource(["Fuzzy search", "Starts with", "Ends with", "Exact match"]); + this.modeSelector.SelectedItem = 0; + this.resultsList = new ListView + { + X = 0, + Y = Pos.Bottom(this.modeSelector) + 1, + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = true, + }; + + searchTab.View.Add(this.queryField, searchButton, this.searchAliasesCheckbox, this.modeSelector, + separatorLine, this.resultsList); + tabView.AddTab(tagTreeTab, true); + tabView.AddTab(searchTab, false); + this.Add(tabView); + this.Enabled = false; + + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; + TagDatabaseService.TagAdded += this.TagDatabaseService_OnTagAdded; + TagDatabaseService.TagDeleted += this.TagDatabaseService_OnTagDeleted; + TagDatabaseService.TagSaved += this.TagDatabaseService_OnTagSaved; + TagDatabaseService.SearchFinished += this.TagDatabaseService_OnSearchFinished; + searchButton.Accepting += (_, args) => + { + if (string.IsNullOrWhiteSpace(this.queryField.Text)) return; + bool isChecked = this.searchAliasesCheckbox.CheckedState == CheckState.Checked; + TagDatabaseService.SearchTags(this.queryField.Text, + (TagDatabaseSearchMode)this.modeSelector.SelectedItem, isChecked); + args.Handled = true; + }; + this.queryField.KeyDown += (_, args) => + { + if (args.KeyCode != Key.Enter || string.IsNullOrWhiteSpace(this.queryField.Text)) return; + bool isChecked = this.searchAliasesCheckbox.CheckedState == CheckState.Checked; + TagDatabaseService.SearchTags(this.queryField.Text, + (TagDatabaseSearchMode)this.modeSelector.SelectedItem, false); + args.Handled = true; + }; + + this.tagTree.ObjectActivated += (_, args) => + { + if (args.ActivatedObject is TagHierarchyTreeNode node) + Program.MainView.EditPane.LoadTag(node.AssociatedTag); + }; + + this.resultsList.OpenSelectedItem += (_, args) => + { + if (args.Value is not Tag selectedTag || this.resultsListAutoSelect) return; + Program.MainView.EditPane.LoadTag(selectedTag); + }; + } + + private void RefreshTagTree(TagDatabase db) + { + this.tagTree.ClearObjects(); + IEnumerable topLevelNodes = + db.Tags + .Where(tag => tag.IsTopLevel) + .OrderBy(tag => tag.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(tag => new TagHierarchyTreeNode(tag)); + this.tagTree.AddObjects(topLevelNodes); + } + + private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) + { + if (sender is not TagDatabase db) return; + this.RefreshTagTree(db); + } + + private void TagDatabaseService_OnTagAdded(object? sender, Tag tag) + { + foreach (int parent in tag.ParentIds) + this.tagTree.Objects.Cast().Where(node => node.AssociatedTag.Id == parent) + .ToList() + .ForEach(node => this.tagTree.RefreshObject(node)); + } + + private void TagDatabaseService_OnTagDeleted(object? sender, (int Id, string Name) tag) + { + this.tagTree.Objects.Cast() + .Where(node => node.AssociatedTag.Id == tag.Id).ToList() + .ForEach(node => this.tagTree.Remove(node)); + this.resultsList.SetSource(new ObservableCollection(Array.Empty())); + } + + private void TagDatabaseService_OnTagSaved(object? sender, Tag tag) + { + if (sender is not TagDatabase db) return; + List existingNodes = this.tagTree.Objects.Cast() + .Where(node => node.AssociatedTag.Id == tag.Id).ToList(); + + switch (tag.IsTopLevel) + { + case false when existingNodes.All(node => !node.IsChildNode): + { + foreach (TagHierarchyTreeNode node in existingNodes) this.tagTree.Remove(node); + + break; + } + case true: + this.tagTree.AddObject(new TagHierarchyTreeNode(tag)); + // this feels jank but it works. + // i wish there was smth better than refreshing the entire tree but terminal.gui doesn't do sorting + // so this'll do. + // the user may expect to see their changes immediately reflected anyway. + this.RefreshTagTree(db); + break; + } + + foreach (TagHierarchyTreeNode node in + this.tagTree.Objects.Cast() + .Where(node => node.AssociatedTag.Id == tag.Id)) + this.tagTree.RefreshObject(node); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/TagEditorPane.cs b/UI/Terminal/Views/EditorWindow/TagEditorPane.cs new file mode 100644 index 0000000..d9a51a3 --- /dev/null +++ b/UI/Terminal/Views/EditorWindow/TagEditorPane.cs @@ -0,0 +1,280 @@ +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A paned containing the tag editor functionality of the application. +/// +public class TagEditorPane : View +{ + private readonly TextField aliasesField = new(); + private readonly TextField bindingsField = new(); + + private readonly TextField nameField = new(); + private readonly TextView notesField = new(); + private readonly TextField parentsField = new(); + private readonly List textFields; + private readonly CheckBox topLevelCheckBox = new(); + private Tag? currentTag; + private Button deleteButton = null!; + + /// + /// Initializes a new instance of the class. + /// + public TagEditorPane() + { + this.InitialiseUI(); + this.textFields = [this.nameField, this.parentsField, this.bindingsField, this.aliasesField]; + } + + internal bool CheckForUnsavedChanges() + { + return this.textFields.Any(f => f.HasHistoryChanges) || this.notesField.HasHistoryChanges; + } + + internal void EnableIfTagLoaded() + { + this.Enabled = this.currentTag is not null; + } + + internal void LoadTag(Tag tag) + { + this.currentTag = tag; + this.UpdateTagDetails(); + this.Enabled = true; + } + + private void AddButton_OnAccepting(object? sender, CommandEventArgs e) + { + if (Program.CurrentDatabase != null) + this.currentTag = new Tag + { + Name = string.Empty, + IsTopLevel = false, + TagBindings = Program.CurrentDatabase.DefaultTagBindings, + }; + else return; + + if (!this.Enabled) this.Enabled = true; + this.deleteButton.Enabled = false; + this.UpdateTagDetails(); + e.Handled = true; + } + + private void ClearTextFieldHistory() + { + this.textFields.ForEach(f => f.ClearHistoryChanges()); + this.notesField.ClearHistoryChanges(); + } + + private async void DeleteButton_OnAccepting(object? sender, EventArgs e) + { + try + { + if (this.currentTag is null) return; + int result = MessageBox.Query("Are you sure?", + $"Are you sure you want to delete the tag '{this.currentTag.Name}'?", 1, "Yes", "No"); + if (result != 0) return; + + await TagDatabaseService.DeleteTag(this.currentTag); + this.currentTag = null; + this.Disable(); + } + catch (Exception ex) + { + MessageBox.ErrorQuery( + "Error", + $"An error has occurred trying to delete the tag.\n\nError message:\n{ex.Message}", + "OK"); + } + } + + private void Disable() + { + this.Enabled = false; + this.textFields.ForEach(f => f.Text = string.Empty); + this.notesField.Text = string.Empty; + this.ClearTextFieldHistory(); + this.topLevelCheckBox.CheckedState = CheckState.UnChecked; + } + + private void InitialiseUI() + { + Label nameLabel = new() + { + X = 0, + Text = "Name: ", + }; + Label parentsLabel = new() + { + Y = Pos.Bottom(nameLabel), + Text = "Parents: ", + CanFocus = false, + }; + Label bindingsLabel = new() + { + Y = Pos.Bottom(parentsLabel), + Text = "Tag bindings: ", + CanFocus = false, + }; + Label aliasesLabel = new() + { + Y = Pos.Bottom(bindingsLabel), + Text = "A.K.As: ", + CanFocus = false, + }; + + this.nameField.X = Pos.Right(nameLabel); + this.nameField.Width = Dim.Fill(); + this.nameField.CanFocus = true; + this.nameField.Height = 1; + + this.parentsField.X = Pos.Right(parentsLabel); + this.parentsField.Y = Pos.Bottom(this.nameField); + this.parentsField.Width = Dim.Fill(); + this.parentsField.CanFocus = true; + this.parentsField.Height = 1; + + this.bindingsField.Width = Dim.Fill(); + this.bindingsField.X = Pos.Right(bindingsLabel); + this.bindingsField.Y = Pos.Bottom(this.parentsField); + this.bindingsField.CanFocus = true; + this.bindingsField.Height = 1; + + this.aliasesField.Width = Dim.Fill(); + this.aliasesField.X = Pos.Right(aliasesLabel); + this.aliasesField.Y = Pos.Bottom(this.bindingsField); + this.aliasesField.CanFocus = true; + this.aliasesField.Height = 1; + + this.Add(nameLabel, parentsLabel, bindingsLabel, aliasesLabel); + this.Add(this.nameField, this.parentsField, this.bindingsField, this.aliasesField); + + FrameView notesView = new() + { + Height = Dim.Fill() ! - 2, + Width = Dim.Fill(), + Y = Pos.Bottom(this.aliasesField), + Title = "Notes", + CanFocus = true, + TabStop = TabBehavior.TabStop, + }; + + this.notesField.Height = Dim.Fill(); + this.notesField.Width = Dim.Fill(); + this.notesField.CanFocus = true; + this.notesField.TabStop = TabBehavior.TabStop; + + notesView.Add(this.notesField); + + const int buttonWidth = 10; + const int buttonSpacing = 2; + + this.topLevelCheckBox.Title = "Is top level"; + this.topLevelCheckBox.CanFocus = true; + this.topLevelCheckBox.X = 0; + this.topLevelCheckBox.Y = Pos.Bottom(notesView); + this.topLevelCheckBox.Height = Dim.Fill(); + this.topLevelCheckBox.Width = Dim.Fill(); + + Button saveButton = new() + { + Title = "Save", + X = Pos.AnchorEnd(buttonWidth), + Y = Pos.Bottom(notesView), + CanFocus = true, + }; + Button cancelButton = new() + { + Title = "Cancel", + X = Pos.Left(saveButton) - buttonSpacing - buttonWidth, + Y = Pos.Bottom(notesView), + CanFocus = true, + }; + this.deleteButton = new Button + { + Title = "Delete", + X = Pos.Left(cancelButton) - buttonSpacing - buttonWidth, + Y = Pos.Bottom(notesView), + CanFocus = true, + }; + + this.Add(notesView); + this.Add(this.topLevelCheckBox, this.deleteButton, cancelButton, saveButton); + this.Initialized += this.OnInitialised; + this.deleteButton.Accepting += this.DeleteButton_OnAccepting; + saveButton.Accepting += this.SaveButton_OnAccepting; + cancelButton.Accepting += (_, _) => + { + if (this.currentTag is not null) + this.UpdateTagDetails(false); + }; + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; + } + + private void OnInitialised(object? sender, EventArgs e) + { + Program.TopView.Window.TagPane.NewTagButton.Accepting += this.AddButton_OnAccepting; + } + + private async void SaveButton_OnAccepting(object? sender, CommandEventArgs e) + { + try + { + if (this.currentTag is null) return; + + this.currentTag.Name = this.nameField.Text; + this.currentTag.Notes = this.notesField.Text; + this.currentTag.IsTopLevel = this.topLevelCheckBox.CheckedState == CheckState.Checked; + this.currentTag.Parents = this.parentsField.Text.Split( + ";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + this.currentTag.TagBindings = this.bindingsField.Text.Replace("::", string.Empty) + .Split(";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + this.currentTag.Aliases = this.aliasesField.Text.Split( + ";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + this.currentTag.Validate(); + + await TagDatabaseService.WriteTagToDatabase(this.currentTag); + + this.ClearTextFieldHistory(); + this.deleteButton.Enabled = true; + + e.Handled = true; + } + catch (Exception ex) + { + MessageBox.ErrorQuery( + "Error", + $"An error has occurred trying to save the tag.\n\nError message:\n{ex.Message}", + "OK"); + e.Handled = true; + } + } + + private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) + { + if (sender is not TagDatabase) return; + this.Disable(); + this.currentTag = null; + } + + private void UpdateTagDetails(bool clearHistory = true) + { + if (this.currentTag is null) return; + this.nameField.Text = this.currentTag.Name; + this.notesField.Text = this.currentTag.Notes; + this.parentsField.Text = string.Join("; ", this.currentTag.Parents); + this.bindingsField.Text = string.Join("; ", this.currentTag.TagBindings); + this.aliasesField.Text = string.Join("; ", this.currentTag.Aliases); + + this.topLevelCheckBox.CheckedState = this.currentTag.IsTopLevel ? CheckState.Checked : CheckState.UnChecked; + + if (!clearHistory) return; + this.textFields.ForEach(f => f.ClearHistoryChanges()); + this.notesField.ClearHistoryChanges(); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs b/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs new file mode 100644 index 0000000..26af26b --- /dev/null +++ b/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs @@ -0,0 +1,27 @@ +using TagHierarchyManager.Models; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +internal class TagHierarchyTreeNode(Tag tag) : TreeNode +{ + public Tag AssociatedTag { get; } = tag; + public override IList Children => this.GetChildren().ToList(); + public bool IsChildNode { get; private init; } + public override string Text => this.AssociatedTag.ToString(); + + private IEnumerable GetChildren() + { + if (Program.CurrentDatabase is null) return []; + + IEnumerable children = + Program.CurrentDatabase.Tags + .Where(tag => tag.ParentIds.Contains(this.AssociatedTag.Id)) + .Select(tag => new TagHierarchyTreeNode(tag) + { + IsChildNode = true, + }) + .OrderBy(tagNode => tagNode.AssociatedTag.Name, StringComparer.CurrentCultureIgnoreCase); + return children; + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/StandardDialogs.cs b/UI/Terminal/Views/StandardDialogs.cs new file mode 100644 index 0000000..1f5cb40 --- /dev/null +++ b/UI/Terminal/Views/StandardDialogs.cs @@ -0,0 +1,159 @@ +using TagHierarchyManager.Common; +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A class storing standard dialogs. +/// +internal static class StandardDialogs +{ + /// + /// Shows a containing the name, copyright, license and GitHub link. + /// + public static void AboutMessageBox() + { + const string message = """ + Tag Hierarchy Manager + (c) Flaky 2025- + + Licensed under MIT + + https://github.com/FlakyBlueJay/TagHierarchyManager + + """; + MessageBox.Query("About Tag Hierarchy Manager", message, "OK"); + } + + /// + /// Calls and cancels Closing event if the user chooses + /// to cancel. + /// + /// The object, for cancelling if the user chooses to cancel. + public static void CallUnsavedChangesMessageBox(ToplevelClosingEventArgs e) + { + int result = UnsavedChangesMessageBox(); + if (result == 1) e.Cancel = true; + } + + /// + /// Opens an with the purpose of opening a database. + /// + /// The result of a , the path of the chosen .thdb file. + public static string? DatabaseOpenDialog() + { + List allowedTypes = + [ + new AllowedType(FileTypes.TagDatabase.Name, FileTypes.TagDatabase.FileExtension), + ]; + + return SingleFileOpenDialog("Open a tag hierarchy database...", allowedTypes); + } + + /// + /// Runs a for the purpose of creating a new database. + /// + /// + /// A created with the file path and choice of whether the user approved + /// overwriting the file from the resulting . + /// + public static (string filePath, bool userAllowedOverwrite)? ExportSaveDialog() + { + List allowedTypes = []; + FileTypes.AllNonDatabaseFileTypes.ForEach(fileType => + allowedTypes.Add(new AllowedType(fileType.Name, fileType.FileExtension))); + return RunSaveDialog(allowedTypes); + } + + /// + /// Opens an with the purpose of choosing a file to import. + /// + /// The result of a , the path of the chosen file to import. + public static string? ImportOpenDialog() + { + List allowedTypes = ConvertFileTypesToAllowedTypes(); + return SingleFileOpenDialog("Choose file to import...", allowedTypes); + } + + /// + /// Runs a for the purpose of creating a new database. + /// + /// + /// A created with the file path and choice of whether the user approved + /// overwriting the file from the resulting . + /// + public static (string fileName, bool userAllowedOverwrite)? NewDatabaseSaveDialog() + { + List allowedTypes = + [ + new AllowedType(FileTypes.TagDatabase.Name, FileTypes.TagDatabase.FileExtension), + ]; + + return RunSaveDialog(allowedTypes); + } + + private static List ConvertFileTypesToAllowedTypes() + { + return FileTypes.AllNonDatabaseFileTypes.Select(IAllowedType (fileType) => + new AllowedType(fileType.Name, fileType.FileExtension)) + .ToList(); + } + + private static int OverwriteMessageBox(string path) + { + return MessageBox.Query( + "Overwrite existing file?", + $"A file already exists at\n{path}\n\nAre you sure you want to overwrite this file?", + 1, + "Yes", + "No"); + } + + private static (string filePath, bool userAllowedOverwrite)? RunSaveDialog(List allowedTypes, + string title = "Save as...") + { + SaveDialog exportDialog = new() + { + AllowedTypes = allowedTypes, + Title = title, + }; + bool overwrite = false; + exportDialog.FilesSelected += (_, e) => + { + if (!File.Exists(e.Dialog.Path)) return; + int result = OverwriteMessageBox(e.Dialog.Path); + if (result == 0) + overwrite = true; + else + e.Cancel = true; + }; + Application.Run(exportDialog); + return !exportDialog.Canceled ? (exportDialog.FileName, overwrite) : null; + } + + private static string? SingleFileOpenDialog(string title, List allowedTypes) + { + OpenDialog openDialog = new() + { + OpenMode = OpenMode.File, + AllowsMultipleSelection = false, + AllowedTypes = allowedTypes, + Title = title, + Width = Dim.Fill(), + }; + Application.Run(openDialog); + return openDialog.FilePaths.Any() ? openDialog.FilePaths[0] : null; + } + + private static int UnsavedChangesMessageBox() + { + return MessageBox.Query( + "Are you sure?", + "You have unsaved changes. Are you sure you want to leave?", + 1, + "Yes", + "No"); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/TopMenu.cs b/UI/Terminal/Views/TopMenu.cs new file mode 100644 index 0000000..2ee3ebe --- /dev/null +++ b/UI/Terminal/Views/TopMenu.cs @@ -0,0 +1,144 @@ +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.TerminalUI.Services; +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// The top menu bar of the application. +/// +public class TopMenu : MenuBarv2 +{ + private const string NullDatabaseName = ""; + private const string TotalTagsString = "Total tags: {0}"; + + /// + /// The menu item that will initiate the MusicBee tag hierarchy exporting process. + /// + private MenuItemv2 exportToTagHierarchyItem = null!; + + private MenuItemv2 totalTagsItem = null!; + + /// + /// Initializes a new instance of the class. + /// + public TopMenu() + { + this.InitialiseUI(); + } + + private MenuBarItemv2 DbMenu { get; set; } = null!; + + private static void OnNewDatabase() + { + (string fileName, bool userAllowedOverwrite)? newDatabase = StandardDialogs.NewDatabaseSaveDialog(); + if (newDatabase is null) return; + + TagDatabaseService.CreateDatabase(newDatabase.Value); + } + + private void InitialiseUI() + { + this.exportToTagHierarchyItem = new MenuItemv2( + "_Export to MusicBee tag hierarchy template", + string.Empty, + TagDatabaseService.StartExportProcess) + { + Enabled = false, + }; + + this.DbMenu = new MenuBarItemv2( + NullDatabaseName) + { + Enabled = false, + }; + + this.totalTagsItem = new MenuItemv2( + TotalTagsString, + string.Empty, + null); + this.totalTagsItem.Data = 0; + + MenuBarItemv2 fileMenu = new("_File"); + PopoverMenu filePopover = new([ + new MenuItemv2("_New database...", string.Empty, OnNewDatabase), + new MenuItemv2( + "New from MusicBee _tag hierarchy template", + string.Empty, + () => + { + Program.MainView.ImportDialog = new ImportDialog(); + Application.Run(Program.MainView.ImportDialog); + }), + new Line(), + new MenuItemv2("_Open database...", string.Empty, TagDatabaseService.PromptDatabaseLoad), + new Line(), + this.exportToTagHierarchyItem, + new Line(), + new MenuItemv2("_Quit", string.Empty, () => Application.RequestStop()), + ]); + + fileMenu.PopoverMenu = filePopover; + + MenuBarItemv2 helpMenu = new("_Help"); + PopoverMenu helpPopover = new([ + new MenuItemv2("About", string.Empty, StandardDialogs.AboutMessageBox), + ]); + + helpMenu.PopoverMenu = helpPopover; + + PopoverMenu dbPopover = new([ + new MenuItemv2( + "Database settings", + string.Empty, + () => Application.Run(new DbSettingsDialog())), + + new Line(), + this.totalTagsItem, + ]); + + View lineContainer = new() + { + Width = 1, + Height = Dim.Fill(), + }; + lineContainer.Add(new Line { Orientation = Orientation.Vertical, Width = 1, Height = Dim.Fill() }); + this.DbMenu.PopoverMenu = dbPopover; + this.Add(fileMenu, helpMenu, lineContainer, this.DbMenu); + TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; + } + + private void TagDatabase_OnTagAdded(object? sender, Tag? tag) + { + int currentTagCount = (int)this.totalTagsItem.Data!; + currentTagCount++; + this.UpdateTotalTagsCount(currentTagCount); + } + + private void TagDatabase_OnTagDeleted(object? sender, (int, string) tag) + { + int currentTagCount = (int)this.totalTagsItem.Data!; + currentTagCount--; + this.UpdateTotalTagsCount(currentTagCount); + } + + private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) + { + if (sender is not TagDatabase db) return; + this.DbMenu.Title = $"Current database: {db.Name}"; + this.DbMenu.Enabled = true; + this.totalTagsItem.Title = string.Format(TotalTagsString, db.Tags.Count); + this.totalTagsItem.Data = db.Tags.Count; + TagDatabaseService.TagAdded += this.TagDatabase_OnTagAdded; + TagDatabaseService.TagDeleted += this.TagDatabase_OnTagDeleted; + this.exportToTagHierarchyItem.Enabled = true; + } + + private void UpdateTotalTagsCount(int newCount) + { + this.totalTagsItem.Data = newCount; + this.totalTagsItem.Title = string.Format(TotalTagsString, newCount); + } +} \ No newline at end of file diff --git a/UI/Terminal/Views/TopView.cs b/UI/Terminal/Views/TopView.cs new file mode 100644 index 0000000..3fbe659 --- /dev/null +++ b/UI/Terminal/Views/TopView.cs @@ -0,0 +1,47 @@ +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TagHierarchyManager.UI.TerminalUI.Views; + +/// +/// A top-level view that encapsulates the main view of the application. +/// +public class TopView : Toplevel +{ + /// + /// Gets the associated with the main view. + /// + public EditorWindow Window = null!; + + private TopMenu topMenu = null!; + + /// + /// Initializes a new instance of the class. + /// + public TopView() + { + this.InitialiseUI(); + } + + private void InitialiseUI() + { + // remember to look at the QuitKey property. + this.topMenu = new TopMenu + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = 1, + }; + + this.Window = new EditorWindow(this.topMenu); + + this.Add(this.topMenu, this.Window); + this.Closing += this.OnToplevelClosing; + } + + private void OnToplevelClosing(object? sender, ToplevelClosingEventArgs e) + { + if (this.Window.EditPane.CheckForUnsavedChanges()) StandardDialogs.CallUnsavedChangesMessageBox(e); + } +} \ No newline at end of file