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