diff --git a/TagHierarchyManager.sln b/TagHierarchyManager.sln index 9ea0ac1..6d3e6ea 100644 --- a/TagHierarchyManager.sln +++ b/TagHierarchyManager.sln @@ -9,10 +9,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{397693A2 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 @@ -30,13 +26,8 @@ Global {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/UI/Terminal/Program.cs b/UI/Terminal/Program.cs deleted file mode 100644 index 653e9b3..0000000 --- a/UI/Terminal/Program.cs +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 6bb4f1b..0000000 --- a/UI/Terminal/Properties/PublishProfiles/Linux.pubxml +++ /dev/null @@ -1,12 +0,0 @@ - - - - linux-x64 - true - true - full - true - none - false - - diff --git a/UI/Terminal/Properties/PublishProfiles/Windows.pubxml b/UI/Terminal/Properties/PublishProfiles/Windows.pubxml deleted file mode 100644 index 4e8b01c..0000000 --- a/UI/Terminal/Properties/PublishProfiles/Windows.pubxml +++ /dev/null @@ -1,12 +0,0 @@ - - - - win-x64 - true - true - full - true - none - false - - diff --git a/UI/Terminal/Services/TagDatabaseService.cs b/UI/Terminal/Services/TagDatabaseService.cs deleted file mode 100644 index 4796970..0000000 --- a/UI/Terminal/Services/TagDatabaseService.cs +++ /dev/null @@ -1,266 +0,0 @@ -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 deleted file mode 100644 index a51b83c..0000000 --- a/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - 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 deleted file mode 100644 index dcf9c9b..0000000 --- a/UI/Terminal/TagHierarchyManager.UI.TerminalUI.csproj.DotSettings +++ /dev/null @@ -1,7 +0,0 @@ - - True - True \ No newline at end of file diff --git a/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs b/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs deleted file mode 100644 index 7876294..0000000 --- a/UI/Terminal/Views/Dialogs/DbSettingsDialog.cs +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index a840ebf..0000000 --- a/UI/Terminal/Views/Dialogs/ImportDialog.cs +++ /dev/null @@ -1,234 +0,0 @@ -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 deleted file mode 100644 index 3009fe5..0000000 --- a/UI/Terminal/Views/Dialogs/UserInputDialog.cs +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 6de7f78..0000000 --- a/UI/Terminal/Views/EditorWindow/EditorWindow.cs +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index d88bee0..0000000 --- a/UI/Terminal/Views/EditorWindow/MultiModeStatusBar.cs +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 5f5e87e..0000000 --- a/UI/Terminal/Views/EditorWindow/TagBrowserPane.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.Collections.ObjectModel; -using TagHierarchyManager.Common; -using TagHierarchyManager.Models; -using TagHierarchyManager.UI.TerminalUI.Services; -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -/// -/// A paned view for browsing the 's tag hierarchy tree. -/// -public class TagBrowserPane : View -{ - internal readonly Button NewTagButton = new(); - private ComboBox modeSelector = null!; - private TextField queryField = null!; - private ListView resultsList = null!; - private TreeView tagTree = null!; - private CheckBox searchAliasesCheckbox = null!; - private bool resultsListAutoSelect = true; - - /// - /// Initializes a new instance of the class. - /// - public TagBrowserPane() - { - this.InitialiseUI(); - } - - private void TagDatabaseService_OnSearchFinished(object? sender, List results) - { - this.resultsListAutoSelect = true; - if (results.Count > 0) - this.resultsList.SetSource(new ObservableCollection(results)); - else - this.resultsList.SetSource(["No results found."]); - this.resultsList.SelectedItem = 0; - this.resultsListAutoSelect = false; - } - - private void InitialiseUI() - { - TabView tabView = new() - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true, - }; - - Tab tagTreeTab = new() - { - DisplayText = "Hierarchy Tree", - View = new View - { - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true, - }, - }; - - this.tagTree = new TreeView - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = Dim.Fill() ! - 2, - CanFocus = true, - MultiSelect = false, - }; - - this.NewTagButton.Y = Pos.Bottom(this.tagTree); - this.NewTagButton.Title = "New tag..."; - this.NewTagButton.Width = Dim.Fill(); - this.NewTagButton.CanFocus = true; - - tagTreeTab.View.Add(this.tagTree, this.NewTagButton); - - Tab searchTab = new() - { - DisplayText = "Search", - View = new View - { - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true, - }, - }; - - this.queryField = new TextField - { - Width = Dim.Percent(75), - CanFocus = true, - Height = 1, - }; - this.modeSelector = new ComboBox - { - Y = Pos.Bottom(this.queryField) + 2, - Width = Dim.Fill(), - Height = 5, - CanFocus = true, - ReadOnly = true, - }; - this.searchAliasesCheckbox = new CheckBox - { - Y = Pos.Top(this.modeSelector) - 1, - Text = "Search in aliases", - CanFocus = true, - }; - Line separatorLine = new() - { - Y = Pos.Bottom(this.modeSelector), - }; - Button searchButton = new() - { - X = Pos.Right(this.queryField), - Y = 0, - Text = "Search", - Width = Dim.Fill(), - CanFocus = true, - }; - this.modeSelector.SetSource(["Fuzzy search", "Starts with", "Ends with", "Exact match"]); - this.modeSelector.SelectedItem = 0; - this.resultsList = new ListView - { - X = 0, - Y = Pos.Bottom(this.modeSelector) + 1, - Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true, - }; - - searchTab.View.Add(this.queryField, searchButton, this.searchAliasesCheckbox, this.modeSelector, - separatorLine, this.resultsList); - tabView.AddTab(tagTreeTab, true); - tabView.AddTab(searchTab, false); - this.Add(tabView); - this.Enabled = false; - - TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; - TagDatabaseService.TagAdded += this.TagDatabaseService_OnTagAdded; - TagDatabaseService.TagDeleted += this.TagDatabaseService_OnTagDeleted; - TagDatabaseService.TagSaved += this.TagDatabaseService_OnTagSaved; - TagDatabaseService.SearchFinished += this.TagDatabaseService_OnSearchFinished; - searchButton.Accepting += (_, args) => - { - if (string.IsNullOrWhiteSpace(this.queryField.Text)) return; - bool isChecked = this.searchAliasesCheckbox.CheckedState == CheckState.Checked; - TagDatabaseService.SearchTags(this.queryField.Text, - (TagDatabaseSearchMode)this.modeSelector.SelectedItem, isChecked); - args.Handled = true; - }; - this.queryField.KeyDown += (_, args) => - { - if (args.KeyCode != Key.Enter || string.IsNullOrWhiteSpace(this.queryField.Text)) return; - bool isChecked = this.searchAliasesCheckbox.CheckedState == CheckState.Checked; - TagDatabaseService.SearchTags(this.queryField.Text, - (TagDatabaseSearchMode)this.modeSelector.SelectedItem, false); - args.Handled = true; - }; - - this.tagTree.ObjectActivated += (_, args) => - { - if (args.ActivatedObject is TagHierarchyTreeNode node) - Program.MainView.EditPane.LoadTag(node.AssociatedTag); - }; - - this.resultsList.OpenSelectedItem += (_, args) => - { - if (args.Value is not Tag selectedTag || this.resultsListAutoSelect) return; - Program.MainView.EditPane.LoadTag(selectedTag); - }; - } - - private void RefreshTagTree(TagDatabase db) - { - this.tagTree.ClearObjects(); - IEnumerable topLevelNodes = - db.Tags - .Where(tag => tag.IsTopLevel) - .OrderBy(tag => tag.Name, StringComparer.CurrentCultureIgnoreCase) - .Select(tag => new TagHierarchyTreeNode(tag)); - this.tagTree.AddObjects(topLevelNodes); - } - - private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) - { - if (sender is not TagDatabase db) return; - this.RefreshTagTree(db); - } - - private void TagDatabaseService_OnTagAdded(object? sender, Tag tag) - { - foreach (int parent in tag.ParentIds) - this.tagTree.Objects.Cast().Where(node => node.AssociatedTag.Id == parent) - .ToList() - .ForEach(node => this.tagTree.RefreshObject(node)); - } - - private void TagDatabaseService_OnTagDeleted(object? sender, (int Id, string Name) tag) - { - this.tagTree.Objects.Cast() - .Where(node => node.AssociatedTag.Id == tag.Id).ToList() - .ForEach(node => this.tagTree.Remove(node)); - this.resultsList.SetSource(new ObservableCollection(Array.Empty())); - } - - private void TagDatabaseService_OnTagSaved(object? sender, Tag tag) - { - if (sender is not TagDatabase db) return; - List existingNodes = this.tagTree.Objects.Cast() - .Where(node => node.AssociatedTag.Id == tag.Id).ToList(); - - switch (tag.IsTopLevel) - { - case false when existingNodes.All(node => !node.IsChildNode): - { - foreach (TagHierarchyTreeNode node in existingNodes) this.tagTree.Remove(node); - - break; - } - case true: - this.tagTree.AddObject(new TagHierarchyTreeNode(tag)); - // this feels jank but it works. - // i wish there was smth better than refreshing the entire tree but terminal.gui doesn't do sorting - // so this'll do. - // the user may expect to see their changes immediately reflected anyway. - this.RefreshTagTree(db); - break; - } - - foreach (TagHierarchyTreeNode node in - this.tagTree.Objects.Cast() - .Where(node => node.AssociatedTag.Id == tag.Id)) - this.tagTree.RefreshObject(node); - } -} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/TagEditorPane.cs b/UI/Terminal/Views/EditorWindow/TagEditorPane.cs deleted file mode 100644 index d9a51a3..0000000 --- a/UI/Terminal/Views/EditorWindow/TagEditorPane.cs +++ /dev/null @@ -1,280 +0,0 @@ -using TagHierarchyManager.Models; -using TagHierarchyManager.UI.TerminalUI.Services; -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -/// -/// A paned containing the tag editor functionality of the application. -/// -public class TagEditorPane : View -{ - private readonly TextField aliasesField = new(); - private readonly TextField bindingsField = new(); - - private readonly TextField nameField = new(); - private readonly TextView notesField = new(); - private readonly TextField parentsField = new(); - private readonly List textFields; - private readonly CheckBox topLevelCheckBox = new(); - private Tag? currentTag; - private Button deleteButton = null!; - - /// - /// Initializes a new instance of the class. - /// - public TagEditorPane() - { - this.InitialiseUI(); - this.textFields = [this.nameField, this.parentsField, this.bindingsField, this.aliasesField]; - } - - internal bool CheckForUnsavedChanges() - { - return this.textFields.Any(f => f.HasHistoryChanges) || this.notesField.HasHistoryChanges; - } - - internal void EnableIfTagLoaded() - { - this.Enabled = this.currentTag is not null; - } - - internal void LoadTag(Tag tag) - { - this.currentTag = tag; - this.UpdateTagDetails(); - this.Enabled = true; - } - - private void AddButton_OnAccepting(object? sender, CommandEventArgs e) - { - if (Program.CurrentDatabase != null) - this.currentTag = new Tag - { - Name = string.Empty, - IsTopLevel = false, - TagBindings = Program.CurrentDatabase.DefaultTagBindings, - }; - else return; - - if (!this.Enabled) this.Enabled = true; - this.deleteButton.Enabled = false; - this.UpdateTagDetails(); - e.Handled = true; - } - - private void ClearTextFieldHistory() - { - this.textFields.ForEach(f => f.ClearHistoryChanges()); - this.notesField.ClearHistoryChanges(); - } - - private async void DeleteButton_OnAccepting(object? sender, EventArgs e) - { - try - { - if (this.currentTag is null) return; - int result = MessageBox.Query("Are you sure?", - $"Are you sure you want to delete the tag '{this.currentTag.Name}'?", 1, "Yes", "No"); - if (result != 0) return; - - await TagDatabaseService.DeleteTag(this.currentTag); - this.currentTag = null; - this.Disable(); - } - catch (Exception ex) - { - MessageBox.ErrorQuery( - "Error", - $"An error has occurred trying to delete the tag.\n\nError message:\n{ex.Message}", - "OK"); - } - } - - private void Disable() - { - this.Enabled = false; - this.textFields.ForEach(f => f.Text = string.Empty); - this.notesField.Text = string.Empty; - this.ClearTextFieldHistory(); - this.topLevelCheckBox.CheckedState = CheckState.UnChecked; - } - - private void InitialiseUI() - { - Label nameLabel = new() - { - X = 0, - Text = "Name: ", - }; - Label parentsLabel = new() - { - Y = Pos.Bottom(nameLabel), - Text = "Parents: ", - CanFocus = false, - }; - Label bindingsLabel = new() - { - Y = Pos.Bottom(parentsLabel), - Text = "Tag bindings: ", - CanFocus = false, - }; - Label aliasesLabel = new() - { - Y = Pos.Bottom(bindingsLabel), - Text = "A.K.As: ", - CanFocus = false, - }; - - this.nameField.X = Pos.Right(nameLabel); - this.nameField.Width = Dim.Fill(); - this.nameField.CanFocus = true; - this.nameField.Height = 1; - - this.parentsField.X = Pos.Right(parentsLabel); - this.parentsField.Y = Pos.Bottom(this.nameField); - this.parentsField.Width = Dim.Fill(); - this.parentsField.CanFocus = true; - this.parentsField.Height = 1; - - this.bindingsField.Width = Dim.Fill(); - this.bindingsField.X = Pos.Right(bindingsLabel); - this.bindingsField.Y = Pos.Bottom(this.parentsField); - this.bindingsField.CanFocus = true; - this.bindingsField.Height = 1; - - this.aliasesField.Width = Dim.Fill(); - this.aliasesField.X = Pos.Right(aliasesLabel); - this.aliasesField.Y = Pos.Bottom(this.bindingsField); - this.aliasesField.CanFocus = true; - this.aliasesField.Height = 1; - - this.Add(nameLabel, parentsLabel, bindingsLabel, aliasesLabel); - this.Add(this.nameField, this.parentsField, this.bindingsField, this.aliasesField); - - FrameView notesView = new() - { - Height = Dim.Fill() ! - 2, - Width = Dim.Fill(), - Y = Pos.Bottom(this.aliasesField), - Title = "Notes", - CanFocus = true, - TabStop = TabBehavior.TabStop, - }; - - this.notesField.Height = Dim.Fill(); - this.notesField.Width = Dim.Fill(); - this.notesField.CanFocus = true; - this.notesField.TabStop = TabBehavior.TabStop; - - notesView.Add(this.notesField); - - const int buttonWidth = 10; - const int buttonSpacing = 2; - - this.topLevelCheckBox.Title = "Is top level"; - this.topLevelCheckBox.CanFocus = true; - this.topLevelCheckBox.X = 0; - this.topLevelCheckBox.Y = Pos.Bottom(notesView); - this.topLevelCheckBox.Height = Dim.Fill(); - this.topLevelCheckBox.Width = Dim.Fill(); - - Button saveButton = new() - { - Title = "Save", - X = Pos.AnchorEnd(buttonWidth), - Y = Pos.Bottom(notesView), - CanFocus = true, - }; - Button cancelButton = new() - { - Title = "Cancel", - X = Pos.Left(saveButton) - buttonSpacing - buttonWidth, - Y = Pos.Bottom(notesView), - CanFocus = true, - }; - this.deleteButton = new Button - { - Title = "Delete", - X = Pos.Left(cancelButton) - buttonSpacing - buttonWidth, - Y = Pos.Bottom(notesView), - CanFocus = true, - }; - - this.Add(notesView); - this.Add(this.topLevelCheckBox, this.deleteButton, cancelButton, saveButton); - this.Initialized += this.OnInitialised; - this.deleteButton.Accepting += this.DeleteButton_OnAccepting; - saveButton.Accepting += this.SaveButton_OnAccepting; - cancelButton.Accepting += (_, _) => - { - if (this.currentTag is not null) - this.UpdateTagDetails(false); - }; - TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; - } - - private void OnInitialised(object? sender, EventArgs e) - { - Program.TopView.Window.TagPane.NewTagButton.Accepting += this.AddButton_OnAccepting; - } - - private async void SaveButton_OnAccepting(object? sender, CommandEventArgs e) - { - try - { - if (this.currentTag is null) return; - - this.currentTag.Name = this.nameField.Text; - this.currentTag.Notes = this.notesField.Text; - this.currentTag.IsTopLevel = this.topLevelCheckBox.CheckedState == CheckState.Checked; - this.currentTag.Parents = this.parentsField.Text.Split( - ";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - this.currentTag.TagBindings = this.bindingsField.Text.Replace("::", string.Empty) - .Split(";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - this.currentTag.Aliases = this.aliasesField.Text.Split( - ";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); - this.currentTag.Validate(); - - await TagDatabaseService.WriteTagToDatabase(this.currentTag); - - this.ClearTextFieldHistory(); - this.deleteButton.Enabled = true; - - e.Handled = true; - } - catch (Exception ex) - { - MessageBox.ErrorQuery( - "Error", - $"An error has occurred trying to save the tag.\n\nError message:\n{ex.Message}", - "OK"); - e.Handled = true; - } - } - - private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) - { - if (sender is not TagDatabase) return; - this.Disable(); - this.currentTag = null; - } - - private void UpdateTagDetails(bool clearHistory = true) - { - if (this.currentTag is null) return; - this.nameField.Text = this.currentTag.Name; - this.notesField.Text = this.currentTag.Notes; - this.parentsField.Text = string.Join("; ", this.currentTag.Parents); - this.bindingsField.Text = string.Join("; ", this.currentTag.TagBindings); - this.aliasesField.Text = string.Join("; ", this.currentTag.Aliases); - - this.topLevelCheckBox.CheckedState = this.currentTag.IsTopLevel ? CheckState.Checked : CheckState.UnChecked; - - if (!clearHistory) return; - this.textFields.ForEach(f => f.ClearHistoryChanges()); - this.notesField.ClearHistoryChanges(); - } -} \ No newline at end of file diff --git a/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs b/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs deleted file mode 100644 index 26af26b..0000000 --- a/UI/Terminal/Views/EditorWindow/TagHierarchyTreeNode.cs +++ /dev/null @@ -1,27 +0,0 @@ -using TagHierarchyManager.Models; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -internal class TagHierarchyTreeNode(Tag tag) : TreeNode -{ - public Tag AssociatedTag { get; } = tag; - public override IList Children => this.GetChildren().ToList(); - public bool IsChildNode { get; private init; } - public override string Text => this.AssociatedTag.ToString(); - - private IEnumerable GetChildren() - { - if (Program.CurrentDatabase is null) return []; - - IEnumerable children = - Program.CurrentDatabase.Tags - .Where(tag => tag.ParentIds.Contains(this.AssociatedTag.Id)) - .Select(tag => new TagHierarchyTreeNode(tag) - { - IsChildNode = true, - }) - .OrderBy(tagNode => tagNode.AssociatedTag.Name, StringComparer.CurrentCultureIgnoreCase); - return children; - } -} \ No newline at end of file diff --git a/UI/Terminal/Views/StandardDialogs.cs b/UI/Terminal/Views/StandardDialogs.cs deleted file mode 100644 index 1f5cb40..0000000 --- a/UI/Terminal/Views/StandardDialogs.cs +++ /dev/null @@ -1,159 +0,0 @@ -using TagHierarchyManager.Common; -using Terminal.Gui.App; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -/// -/// A class storing standard dialogs. -/// -internal static class StandardDialogs -{ - /// - /// Shows a containing the name, copyright, license and GitHub link. - /// - public static void AboutMessageBox() - { - const string message = """ - Tag Hierarchy Manager - (c) Flaky 2025- - - Licensed under MIT - - https://github.com/FlakyBlueJay/TagHierarchyManager - - """; - MessageBox.Query("About Tag Hierarchy Manager", message, "OK"); - } - - /// - /// Calls and cancels Closing event if the user chooses - /// to cancel. - /// - /// The object, for cancelling if the user chooses to cancel. - public static void CallUnsavedChangesMessageBox(ToplevelClosingEventArgs e) - { - int result = UnsavedChangesMessageBox(); - if (result == 1) e.Cancel = true; - } - - /// - /// Opens an with the purpose of opening a database. - /// - /// The result of a , the path of the chosen .thdb file. - public static string? DatabaseOpenDialog() - { - List allowedTypes = - [ - new AllowedType(FileTypes.TagDatabase.Name, FileTypes.TagDatabase.FileExtension), - ]; - - return SingleFileOpenDialog("Open a tag hierarchy database...", allowedTypes); - } - - /// - /// Runs a for the purpose of creating a new database. - /// - /// - /// A created with the file path and choice of whether the user approved - /// overwriting the file from the resulting . - /// - public static (string filePath, bool userAllowedOverwrite)? ExportSaveDialog() - { - List allowedTypes = []; - FileTypes.AllNonDatabaseFileTypes.ForEach(fileType => - allowedTypes.Add(new AllowedType(fileType.Name, fileType.FileExtension))); - return RunSaveDialog(allowedTypes); - } - - /// - /// Opens an with the purpose of choosing a file to import. - /// - /// The result of a , the path of the chosen file to import. - public static string? ImportOpenDialog() - { - List allowedTypes = ConvertFileTypesToAllowedTypes(); - return SingleFileOpenDialog("Choose file to import...", allowedTypes); - } - - /// - /// Runs a for the purpose of creating a new database. - /// - /// - /// A created with the file path and choice of whether the user approved - /// overwriting the file from the resulting . - /// - public static (string fileName, bool userAllowedOverwrite)? NewDatabaseSaveDialog() - { - List allowedTypes = - [ - new AllowedType(FileTypes.TagDatabase.Name, FileTypes.TagDatabase.FileExtension), - ]; - - return RunSaveDialog(allowedTypes); - } - - private static List ConvertFileTypesToAllowedTypes() - { - return FileTypes.AllNonDatabaseFileTypes.Select(IAllowedType (fileType) => - new AllowedType(fileType.Name, fileType.FileExtension)) - .ToList(); - } - - private static int OverwriteMessageBox(string path) - { - return MessageBox.Query( - "Overwrite existing file?", - $"A file already exists at\n{path}\n\nAre you sure you want to overwrite this file?", - 1, - "Yes", - "No"); - } - - private static (string filePath, bool userAllowedOverwrite)? RunSaveDialog(List allowedTypes, - string title = "Save as...") - { - SaveDialog exportDialog = new() - { - AllowedTypes = allowedTypes, - Title = title, - }; - bool overwrite = false; - exportDialog.FilesSelected += (_, e) => - { - if (!File.Exists(e.Dialog.Path)) return; - int result = OverwriteMessageBox(e.Dialog.Path); - if (result == 0) - overwrite = true; - else - e.Cancel = true; - }; - Application.Run(exportDialog); - return !exportDialog.Canceled ? (exportDialog.FileName, overwrite) : null; - } - - private static string? SingleFileOpenDialog(string title, List allowedTypes) - { - OpenDialog openDialog = new() - { - OpenMode = OpenMode.File, - AllowsMultipleSelection = false, - AllowedTypes = allowedTypes, - Title = title, - Width = Dim.Fill(), - }; - Application.Run(openDialog); - return openDialog.FilePaths.Any() ? openDialog.FilePaths[0] : null; - } - - private static int UnsavedChangesMessageBox() - { - return MessageBox.Query( - "Are you sure?", - "You have unsaved changes. Are you sure you want to leave?", - 1, - "Yes", - "No"); - } -} \ No newline at end of file diff --git a/UI/Terminal/Views/TopMenu.cs b/UI/Terminal/Views/TopMenu.cs deleted file mode 100644 index 2ee3ebe..0000000 --- a/UI/Terminal/Views/TopMenu.cs +++ /dev/null @@ -1,144 +0,0 @@ -using TagHierarchyManager.Models; -using TagHierarchyManager.UI.TerminalUI.Services; -using Terminal.Gui.App; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -/// -/// The top menu bar of the application. -/// -public class TopMenu : MenuBarv2 -{ - private const string NullDatabaseName = ""; - private const string TotalTagsString = "Total tags: {0}"; - - /// - /// The menu item that will initiate the MusicBee tag hierarchy exporting process. - /// - private MenuItemv2 exportToTagHierarchyItem = null!; - - private MenuItemv2 totalTagsItem = null!; - - /// - /// Initializes a new instance of the class. - /// - public TopMenu() - { - this.InitialiseUI(); - } - - private MenuBarItemv2 DbMenu { get; set; } = null!; - - private static void OnNewDatabase() - { - (string fileName, bool userAllowedOverwrite)? newDatabase = StandardDialogs.NewDatabaseSaveDialog(); - if (newDatabase is null) return; - - TagDatabaseService.CreateDatabase(newDatabase.Value); - } - - private void InitialiseUI() - { - this.exportToTagHierarchyItem = new MenuItemv2( - "_Export to MusicBee tag hierarchy template", - string.Empty, - TagDatabaseService.StartExportProcess) - { - Enabled = false, - }; - - this.DbMenu = new MenuBarItemv2( - NullDatabaseName) - { - Enabled = false, - }; - - this.totalTagsItem = new MenuItemv2( - TotalTagsString, - string.Empty, - null); - this.totalTagsItem.Data = 0; - - MenuBarItemv2 fileMenu = new("_File"); - PopoverMenu filePopover = new([ - new MenuItemv2("_New database...", string.Empty, OnNewDatabase), - new MenuItemv2( - "New from MusicBee _tag hierarchy template", - string.Empty, - () => - { - Program.MainView.ImportDialog = new ImportDialog(); - Application.Run(Program.MainView.ImportDialog); - }), - new Line(), - new MenuItemv2("_Open database...", string.Empty, TagDatabaseService.PromptDatabaseLoad), - new Line(), - this.exportToTagHierarchyItem, - new Line(), - new MenuItemv2("_Quit", string.Empty, () => Application.RequestStop()), - ]); - - fileMenu.PopoverMenu = filePopover; - - MenuBarItemv2 helpMenu = new("_Help"); - PopoverMenu helpPopover = new([ - new MenuItemv2("About", string.Empty, StandardDialogs.AboutMessageBox), - ]); - - helpMenu.PopoverMenu = helpPopover; - - PopoverMenu dbPopover = new([ - new MenuItemv2( - "Database settings", - string.Empty, - () => Application.Run(new DbSettingsDialog())), - - new Line(), - this.totalTagsItem, - ]); - - View lineContainer = new() - { - Width = 1, - Height = Dim.Fill(), - }; - lineContainer.Add(new Line { Orientation = Orientation.Vertical, Width = 1, Height = Dim.Fill() }); - this.DbMenu.PopoverMenu = dbPopover; - this.Add(fileMenu, helpMenu, lineContainer, this.DbMenu); - TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitialised; - } - - private void TagDatabase_OnTagAdded(object? sender, Tag? tag) - { - int currentTagCount = (int)this.totalTagsItem.Data!; - currentTagCount++; - this.UpdateTotalTagsCount(currentTagCount); - } - - private void TagDatabase_OnTagDeleted(object? sender, (int, string) tag) - { - int currentTagCount = (int)this.totalTagsItem.Data!; - currentTagCount--; - this.UpdateTotalTagsCount(currentTagCount); - } - - private void TagDatabaseService_OnInitialised(object? sender, EventArgs e) - { - if (sender is not TagDatabase db) return; - this.DbMenu.Title = $"Current database: {db.Name}"; - this.DbMenu.Enabled = true; - this.totalTagsItem.Title = string.Format(TotalTagsString, db.Tags.Count); - this.totalTagsItem.Data = db.Tags.Count; - TagDatabaseService.TagAdded += this.TagDatabase_OnTagAdded; - TagDatabaseService.TagDeleted += this.TagDatabase_OnTagDeleted; - this.exportToTagHierarchyItem.Enabled = true; - } - - private void UpdateTotalTagsCount(int newCount) - { - this.totalTagsItem.Data = newCount; - this.totalTagsItem.Title = string.Format(TotalTagsString, newCount); - } -} \ No newline at end of file diff --git a/UI/Terminal/Views/TopView.cs b/UI/Terminal/Views/TopView.cs deleted file mode 100644 index 3fbe659..0000000 --- a/UI/Terminal/Views/TopView.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace TagHierarchyManager.UI.TerminalUI.Views; - -/// -/// A top-level view that encapsulates the main view of the application. -/// -public class TopView : Toplevel -{ - /// - /// Gets the associated with the main view. - /// - public EditorWindow Window = null!; - - private TopMenu topMenu = null!; - - /// - /// Initializes a new instance of the class. - /// - public TopView() - { - this.InitialiseUI(); - } - - private void InitialiseUI() - { - // remember to look at the QuitKey property. - this.topMenu = new TopMenu - { - X = 0, - Y = 0, - Width = Dim.Fill(), - Height = 1, - }; - - this.Window = new EditorWindow(this.topMenu); - - this.Add(this.topMenu, this.Window); - this.Closing += this.OnToplevelClosing; - } - - private void OnToplevelClosing(object? sender, ToplevelClosingEventArgs e) - { - if (this.Window.EditPane.CheckForUnsavedChanges()) StandardDialogs.CallUnsavedChangesMessageBox(e); - } -} \ No newline at end of file -- 2.51.2 From dc5a9126cef06f9bf28eb1560ca41b4c04574a3f Mon Sep 17 00:00:00 2001 From: Flaky Date: Fri, 2 Jan 2026 10:59:26 +0000 Subject: [PATCH 02/37] change dependency in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11ad642..b430398 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ An application for managing tree hierarchies for music tags, focusing on the Mus JetBrains Rider is recommended for developing for this project, but Visual Studio Code/VS Codium should work too. ### Dependencies -- Terminal.Gui +- Avalonia - Serilog (for logging/debugging) - NUnit (for tests) -- 2.51.2 From 3018d650a15e7f319b62008231dbab12f36ca5bb Mon Sep 17 00:00:00 2001 From: Flaky Date: Fri, 2 Jan 2026 13:10:37 +0000 Subject: [PATCH 03/37] Add Avalonia UI project + implement main window structure --- TagHierarchyManager.sln | 6 +++ UI/App.axaml | 10 +++++ UI/App.axaml.cs | 23 +++++++++++ UI/MainWindow.axaml | 84 +++++++++++++++++++++++++++++++++++++++++ UI/MainWindow.axaml.cs | 11 ++++++ UI/Program.cs | 21 +++++++++++ UI/UI.csproj | 21 +++++++++++ UI/app.manifest | 18 +++++++++ 8 files changed, 194 insertions(+) create mode 100644 UI/App.axaml create mode 100644 UI/App.axaml.cs create mode 100644 UI/MainWindow.axaml create mode 100644 UI/MainWindow.axaml.cs create mode 100644 UI/Program.cs create mode 100644 UI/UI.csproj create mode 100644 UI/app.manifest diff --git a/TagHierarchyManager.sln b/TagHierarchyManager.sln index 6d3e6ea..0fb2c02 100644 --- a/TagHierarchyManager.sln +++ b/TagHierarchyManager.sln @@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{397693A2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager.Tests", "Tests\TagHierarchyManager.Tests.csproj", "{F26B4A2D-0389-4FE8-8B0D-E491B354AC83}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UI", "UI\UI.csproj", "{045690BF-2FC5-4B0E-9516-8F331BD18219}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,10 @@ Global {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 + {045690BF-2FC5-4B0E-9516-8F331BD18219}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {045690BF-2FC5-4B0E-9516-8F331BD18219}.Debug|Any CPU.Build.0 = Debug|Any CPU + {045690BF-2FC5-4B0E-9516-8F331BD18219}.Release|Any CPU.ActiveCfg = Release|Any CPU + {045690BF-2FC5-4B0E-9516-8F331BD18219}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F26B4A2D-0389-4FE8-8B0D-E491B354AC83} = {397693A2-4BD9-4EA2-B70E-84B0B4DD2BF9} diff --git a/UI/App.axaml b/UI/App.axaml new file mode 100644 index 0000000..ac1e0b5 --- /dev/null +++ b/UI/App.axaml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/UI/App.axaml.cs b/UI/App.axaml.cs new file mode 100644 index 0000000..34093fe --- /dev/null +++ b/UI/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace UI; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/UI/MainWindow.axaml b/UI/MainWindow.axaml new file mode 100644 index 0000000..55008a0 --- /dev/null +++ b/UI/MainWindow.axaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + Ready. + + + + + + + + + + + + + + + + + + Fuzzy search + Starts with + Ends with + Exact match + + + + + + + + + + + + + + Is top level + + + + + + + + + + diff --git a/UI/MainWindow.axaml.cs b/UI/MainWindow.axaml.cs new file mode 100644 index 0000000..90b8e59 --- /dev/null +++ b/UI/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace UI; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/UI/Program.cs b/UI/Program.cs new file mode 100644 index 0000000..74ceefb --- /dev/null +++ b/UI/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; +using System; + +namespace UI; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} \ No newline at end of file diff --git a/UI/UI.csproj b/UI/UI.csproj new file mode 100644 index 0000000..81088bf --- /dev/null +++ b/UI/UI.csproj @@ -0,0 +1,21 @@ + + + WinExe + net10.0 + enable + app.manifest + true + + + + + + + + + + None + All + + + diff --git a/UI/app.manifest b/UI/app.manifest new file mode 100644 index 0000000..a335105 --- /dev/null +++ b/UI/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + -- 2.51.2 From 8d41a9ba90daf4f0a2f6cfe884cd8ed6ea379765 Mon Sep 17 00:00:00 2001 From: Flaky Date: Fri, 2 Jan 2026 13:11:29 +0000 Subject: [PATCH 04/37] Add border to TreeView for UI debugging. --- UI/MainWindow.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI/MainWindow.axaml b/UI/MainWindow.axaml index 55008a0..935dd4b 100644 --- a/UI/MainWindow.axaml +++ b/UI/MainWindow.axaml @@ -42,7 +42,7 @@ - + -- 2.51.2 From 90a9f56feba56b9dac82f22c925b5354a2454867 Mon Sep 17 00:00:00 2001 From: Flaky Date: Fri, 2 Jan 2026 15:35:49 +0000 Subject: [PATCH 05/37] Implement basic structure for the database settings window. --- UI/DatabaseSettingsWindow.axaml | 20 ++++++++++++++++++++ UI/DatabaseSettingsWindow.axaml.cs | 13 +++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 UI/DatabaseSettingsWindow.axaml create mode 100644 UI/DatabaseSettingsWindow.axaml.cs diff --git a/UI/DatabaseSettingsWindow.axaml b/UI/DatabaseSettingsWindow.axaml new file mode 100644 index 0000000..c1b58a1 --- /dev/null +++ b/UI/DatabaseSettingsWindow.axaml @@ -0,0 +1,20 @@ + + + Version: 0 + + Default tag binding: + + + + + + + + diff --git a/UI/DatabaseSettingsWindow.axaml.cs b/UI/DatabaseSettingsWindow.axaml.cs new file mode 100644 index 0000000..cb45139 --- /dev/null +++ b/UI/DatabaseSettingsWindow.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace UI; + +public partial class DatabaseSettingsWindow : Window +{ + public DatabaseSettingsWindow() + { + InitializeComponent(); + } +} \ No newline at end of file -- 2.51.2 From 934f9d4ae8b6a5b378c1607e88722254c8df7cfb Mon Sep 17 00:00:00 2001 From: Flaky Date: Fri, 2 Jan 2026 15:47:46 +0000 Subject: [PATCH 06/37] implement about window strcuture --- UI/AboutWindow.axaml | 25 +++++++++++++++++++++++++ UI/AboutWindow.axaml.cs | 13 +++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 UI/AboutWindow.axaml create mode 100644 UI/AboutWindow.axaml.cs diff --git a/UI/AboutWindow.axaml b/UI/AboutWindow.axaml new file mode 100644 index 0000000..e0acc63 --- /dev/null +++ b/UI/AboutWindow.axaml @@ -0,0 +1,25 @@ + + + + Tag Hierarchy Manager + + + (c) Flaky 2025 + + Licensed under MIT + + + https://github.com/FlakyBlueJay/TagHierarchyManager + + + + + + diff --git a/UI/AboutWindow.axaml.cs b/UI/AboutWindow.axaml.cs new file mode 100644 index 0000000..3201fe5 --- /dev/null +++ b/UI/AboutWindow.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace UI; + +public partial class AboutWindow : Window +{ + public AboutWindow() + { + InitializeComponent(); + } +} \ No newline at end of file -- 2.51.2 From 4da4cdfd08616960f75176c9c28a69bd4218209e Mon Sep 17 00:00:00 2001 From: Flaky Date: Sat, 3 Jan 2026 14:05:15 +0000 Subject: [PATCH 07/37] change name of namespace --- TagHierarchyManager.sln | 2 +- UI/AboutWindow.axaml | 2 +- UI/AboutWindow.axaml.cs | 2 +- UI/App.axaml | 2 +- UI/App.axaml.cs | 2 +- UI/DatabaseSettingsWindow.axaml | 2 +- UI/DatabaseSettingsWindow.axaml.cs | 2 +- UI/MainWindow.axaml | 2 +- UI/MainWindow.axaml.cs | 2 +- UI/Program.cs | 2 +- UI/{UI.csproj => TagHierarchyManager.UI.csproj} | 1 + 11 files changed, 11 insertions(+), 10 deletions(-) rename UI/{UI.csproj => TagHierarchyManager.UI.csproj} (94%) diff --git a/TagHierarchyManager.sln b/TagHierarchyManager.sln index 0fb2c02..46f0b85 100644 --- a/TagHierarchyManager.sln +++ b/TagHierarchyManager.sln @@ -9,7 +9,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{397693A2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager.Tests", "Tests\TagHierarchyManager.Tests.csproj", "{F26B4A2D-0389-4FE8-8B0D-E491B354AC83}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UI", "UI\UI.csproj", "{045690BF-2FC5-4B0E-9516-8F331BD18219}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagHierarchyManager.UI", "UI\TagHierarchyManager.UI.csproj", "{045690BF-2FC5-4B0E-9516-8F331BD18219}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/UI/AboutWindow.axaml b/UI/AboutWindow.axaml index e0acc63..a4a6e52 100644 --- a/UI/AboutWindow.axaml +++ b/UI/AboutWindow.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="180" Width="400" Height="180" - x:Class="UI.AboutWindow" + x:Class="TagHierarchyManager.UI.AboutWindow" Title="AboutWindow"> diff --git a/UI/AboutWindow.axaml.cs b/UI/AboutWindow.axaml.cs index 3201fe5..7461811 100644 --- a/UI/AboutWindow.axaml.cs +++ b/UI/AboutWindow.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; -namespace UI; +namespace TagHierarchyManager.UI; public partial class AboutWindow : Window { diff --git a/UI/App.axaml b/UI/App.axaml index ac1e0b5..cb76ad2 100644 --- a/UI/App.axaml +++ b/UI/App.axaml @@ -1,6 +1,6 @@ diff --git a/UI/App.axaml.cs b/UI/App.axaml.cs index 34093fe..6fb0eca 100644 --- a/UI/App.axaml.cs +++ b/UI/App.axaml.cs @@ -2,7 +2,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -namespace UI; +namespace TagHierarchyManager.UI; public partial class App : Application { diff --git a/UI/DatabaseSettingsWindow.axaml b/UI/DatabaseSettingsWindow.axaml index c1b58a1..029da9c 100644 --- a/UI/DatabaseSettingsWindow.axaml +++ b/UI/DatabaseSettingsWindow.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="160" Width="500" Height="160" - x:Class="UI.DatabaseSettingsWindow" + x:Class="TagHierarchyManager.UI.DatabaseSettingsWindow" Title="Settings - "> Version: 0 diff --git a/UI/DatabaseSettingsWindow.axaml.cs b/UI/DatabaseSettingsWindow.axaml.cs index cb45139..8c5e0d9 100644 --- a/UI/DatabaseSettingsWindow.axaml.cs +++ b/UI/DatabaseSettingsWindow.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; -namespace UI; +namespace TagHierarchyManager.UI; public partial class DatabaseSettingsWindow : Window { diff --git a/UI/MainWindow.axaml b/UI/MainWindow.axaml index 935dd4b..c2d017b 100644 --- a/UI/MainWindow.axaml +++ b/UI/MainWindow.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="UI.MainWindow" + x:Class="TagHierarchyManager.UI.MainWindow" Title="Tag Hierarchy Manager"> - - - + + + - + - - + + - - + + - + - + - Ready. + - + - - + - Fuzzy search - Starts with - Ends with - Exact match + + + + @@ -65,16 +66,16 @@ - - - - - + + + + + - Is top level - - - + + - - - - + + + + - - + - - - - - - - - - - + + + + + - - + + + + + + + + this.DataContext as MainWindowViewModel; + private MainWindowViewModel? ViewModel => this.DataContext as MainWindowViewModel; private static FilePickerFileType MusicBeeTagHierarchy { get; } = new("MusicBee tag hierarchy template") { @@ -47,6 +48,14 @@ public partial class MainWindow : Window await this.ViewModel.SaveSelectedTagAsync(); } + public void ButtonSearch_Click(object? sender, RoutedEventArgs e) + { + this.ViewModel.StartSearch( + this.SearchTextBox.Text, + (TagDatabaseSearchMode)this.SearchModeComboBox.SelectedIndex, + this.SearchAliasesCheckBox.IsChecked ?? false); + } + public async void MenuItemExport_Click(object? sender, RoutedEventArgs e) { var file = await this.StorageProvider.SaveFilePickerAsync( -- 2.51.2 From 7f1f01a003468f2900296e127e82f1d75d9db905 Mon Sep 17 00:00:00 2001 From: Flaky Date: Sun, 11 Jan 2026 16:50:23 +0000 Subject: [PATCH 27/37] update status block text on search, delete tags from search list on deletion, add some strings for file formats to Resources.resx --- UI/Assets/Resources.resx | 18 ++++++++++++++ UI/ViewModels/MainWindowViewModel.cs | 4 ++-- UI/ViewModels/SearchViewModel.cs | 35 +++++++++++++++++++++++----- UI/Views/MainWindow.axaml | 8 ++----- UI/Views/MainWindow.axaml.cs | 14 ++++++++--- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/UI/Assets/Resources.resx b/UI/Assets/Resources.resx index 800383e..5b4cf4a 100644 --- a/UI/Assets/Resources.resx +++ b/UI/Assets/Resources.resx @@ -149,4 +149,22 @@ Unsaved changes + + No tags found. + + + One tag found. + + + {0} tags found. + + + MusicBee tag hierarchy template + + + Tag hierarchy database + + + Search aliases/A.K.As? + \ No newline at end of file diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index ba442ee..a13c884 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -38,8 +38,6 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool _unsavedChanges; - // TODO searchViewModel - public int TotalTags => this.Database?.Tags.Count ?? 0; public string WindowTitle => @@ -197,6 +195,8 @@ public partial class MainWindowViewModel : ViewModelBase Dispatcher.UIThread.Post(async () => { (this.HierarchyTreeViewModel as IDisposable)?.Dispose(); + (this.SearchViewModel as IDisposable)?.Dispose(); + if (this.Database != null) { this.Database.TagAdded -= this.TagDatabase_TagAdded; diff --git a/UI/ViewModels/SearchViewModel.cs b/UI/ViewModels/SearchViewModel.cs index 94d9f50..d03597c 100644 --- a/UI/ViewModels/SearchViewModel.cs +++ b/UI/ViewModels/SearchViewModel.cs @@ -1,38 +1,55 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; using System.Linq; -using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; using TagHierarchyManager.Common; namespace TagHierarchyManager.UI.ViewModels; -public partial class SearchViewModel(MainWindowViewModel mainWindow) : ViewModelBase +public partial class SearchViewModel : ViewModelBase, IDisposable { + private readonly MainWindowViewModel mainWindow; + + public SearchViewModel(MainWindowViewModel mainWindow) + { + this.mainWindow = mainWindow; + mainWindow.Database.TagDeleted += TagDatabase_OnTagDeleted; + } + [ObservableProperty] private ObservableCollection _searchResults = []; [ObservableProperty] private TagItemViewModel? _selectedSearchResult; + public void Dispose() => + mainWindow.Database.TagDeleted -= TagDatabase_OnTagDeleted; + public void Search(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { + if (string.IsNullOrWhiteSpace(searchQuery)) return; + var results = searchAliases ? mainWindow.Database.SearchWithAliases(searchQuery, mode) : mainWindow.Database.Search(searchQuery, mode); + string statusBlockString = string.Empty; this.SearchResults.Clear(); if (results.Count == 0) { // todo resx - mainWindow.StatusBlockText = "No results found."; + mainWindow.StatusBlockText = Assets.Resources.SearchNoResultsFound; return; } - results.Select(tag => new TagItemViewModel(tag, id => mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) .OrderBy(tag => tag.Name) .ToList() .ForEach(this.SearchResults.Add); - + + mainWindow.StatusBlockText = results.Count > 1 + ? string.Format(Assets.Resources.SearchMultipleResultsFound, results.Count) + : Assets.Resources.SearchOneResultFound; + } partial void OnSelectedSearchResultChanged(TagItemViewModel? value) @@ -40,4 +57,10 @@ public partial class SearchViewModel(MainWindowViewModel mainWindow) : ViewModel if (value is null) return; mainWindow.SelectedTag = value; } + + private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) deletedTag) + { + var deletedTagItemVM = this.SearchResults.First(item => item.Tag.Id == deletedTag.id); + this.SearchResults.Remove(deletedTagItemVM); + } } \ No newline at end of file diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml index f9dacdf..aeed164 100644 --- a/UI/Views/MainWindow.axaml +++ b/UI/Views/MainWindow.axaml @@ -13,11 +13,9 @@ TODO: - Import - Database settings - - Search - Error --> - @@ -54,8 +52,6 @@ @@ -81,8 +77,8 @@ - - + + diff --git a/UI/Views/MainWindow.axaml.cs b/UI/Views/MainWindow.axaml.cs index f332b68..f76f2fe 100644 --- a/UI/Views/MainWindow.axaml.cs +++ b/UI/Views/MainWindow.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Platform.Storage; using TagHierarchyManager.Common; @@ -17,13 +18,12 @@ public partial class MainWindow : Window private MainWindowViewModel? ViewModel => this.DataContext as MainWindowViewModel; - private static FilePickerFileType MusicBeeTagHierarchy { get; } = new("MusicBee tag hierarchy template") + private static FilePickerFileType MusicBeeTagHierarchy { get; } = new(Assets.Resources.FileFormatMusicBeeTagHierarchy) { Patterns = ["*.txt"] }; - // TODO resx - private static FilePickerFileType TagDatabaseFileType { get; } = new("Tag Hierarchy Manager database") + private static FilePickerFileType TagDatabaseFileType { get; } = new(Assets.Resources.FileFormatTagHierarchyDatabase) { Patterns = ["*.thdb"] }; @@ -108,6 +108,14 @@ public partial class MainWindow : Window new AboutWindow().ShowDialog(this); } + public void SearchTextBox_OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) return; + this.ButtonSearch_Click(sender, e); + e.Handled = true; + + } + public async void WindowClosing(object? sender, WindowClosingEventArgs e) { if (this._userWantsToQuit) return; -- 2.51.2 From 577d25e5d1c5cf401d8611dc32751998cf579477 Mon Sep 17 00:00:00 2001 From: Flaky Date: Mon, 12 Jan 2026 19:57:32 +0000 Subject: [PATCH 28/37] implement error dialog, tag validation, a bit of cleanup on top of that --- UI/Assets/Resources.resx | 18 +++ UI/ViewModels/ErrorDialogViewModel.cs | 26 ++++ UI/ViewModels/HierarchyTreeViewModel.cs | 24 ++- UI/ViewModels/MainWindowViewModel.cs | 195 +++++++++++++++++------- UI/ViewModels/SearchViewModel.cs | 25 ++- UI/ViewModels/TagItemViewModel.cs | 16 +- UI/Views/AboutWindow.axaml.cs | 5 +- UI/Views/DeleteTagDialog.axaml.cs | 10 +- UI/Views/ErrorDialog.axaml | 19 +++ UI/Views/ErrorDialog.axaml.cs | 16 ++ UI/Views/MainWindow.axaml | 6 +- UI/Views/MainWindow.axaml.cs | 3 +- 12 files changed, 264 insertions(+), 99 deletions(-) create mode 100644 UI/ViewModels/ErrorDialogViewModel.cs create mode 100644 UI/Views/ErrorDialog.axaml create mode 100644 UI/Views/ErrorDialog.axaml.cs diff --git a/UI/Assets/Resources.resx b/UI/Assets/Resources.resx index 5b4cf4a..75844af 100644 --- a/UI/Assets/Resources.resx +++ b/UI/Assets/Resources.resx @@ -167,4 +167,22 @@ Search aliases/A.K.As? + + Tags that are not top level must have a parent. + + + You cannot make tags a parent of themselves. + + + An error has occurred. + + + Tag "{0}" saved successfully. + + + Exporting tag database... + + + Database successfully exported at {0} + \ No newline at end of file diff --git a/UI/ViewModels/ErrorDialogViewModel.cs b/UI/ViewModels/ErrorDialogViewModel.cs new file mode 100644 index 0000000..b87f48d --- /dev/null +++ b/UI/ViewModels/ErrorDialogViewModel.cs @@ -0,0 +1,26 @@ +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using TagHierarchyManager.UI.Assets; +using TagHierarchyManager.UI.Views; + +namespace TagHierarchyManager.UI.ViewModels; + +public partial class ErrorDialogViewModel : ViewModelBase +{ + [ObservableProperty] private string _errorMessage; + + public ErrorDialogViewModel(string message) + { + this.ErrorMessage = message; + } + + public void ShowDialog(Window window) + { + ErrorDialog error = new() + { + DataContext = this, + Title = Resources.ErrorDialogTitle + }; + error.ShowDialog(window); + } +} \ No newline at end of file diff --git a/UI/ViewModels/HierarchyTreeViewModel.cs b/UI/ViewModels/HierarchyTreeViewModel.cs index 1d40b5c..4f84172 100644 --- a/UI/ViewModels/HierarchyTreeViewModel.cs +++ b/UI/ViewModels/HierarchyTreeViewModel.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; -using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using TagHierarchyManager.Models; @@ -27,6 +26,8 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable public void Dispose() { + if (this._mainWindow.Database is null) return; + this._mainWindow.Database.TagUpdated -= this.TagDatabase_OnTagUpdated; this._mainWindow.Database.TagAdded -= this.TagDatabase_OnTagAdded; this._mainWindow.Database.TagDeleted -= this.TagDatabase_OnTagDeleted; @@ -40,7 +41,7 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable } - public async Task SyncHierarchyAsync() + private async Task SyncHierarchyAsync() { if (this._mainWindow.Database is null) return; @@ -100,6 +101,7 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable private void SubscribeToEvents() { + if (this._mainWindow.Database is null) return; this._mainWindow.Database.TagUpdated += this.TagDatabase_OnTagUpdated; this._mainWindow.Database.TagAdded += this.TagDatabase_OnTagAdded; this._mainWindow.Database.TagDeleted += this.TagDatabase_OnTagDeleted; @@ -144,18 +146,12 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable parentVm.SyncChildren(childVms); } - private void TagDatabase_OnTagAdded(object? sender, Tag _) - { - Dispatcher.UIThread.InvokeAsync(this.OnTreeUpdate); - } + private void TagDatabase_OnTagAdded(object? sender, Tag tag) => + _ = Task.Run(async () => await this.OnTreeUpdate()); - private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) _) - { - Dispatcher.UIThread.InvokeAsync(this.OnTreeUpdate); - } + private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) tag) => + _ = Task.Run(async () => await this.OnTreeUpdate()); - private void TagDatabase_OnTagUpdated(object? sender, Tag _) - { - Dispatcher.UIThread.InvokeAsync(this.OnTreeUpdate); - } + private void TagDatabase_OnTagUpdated(object? sender, Tag tag) => + _ = Task.Run(async () => await this.OnTreeUpdate()); } \ No newline at end of file diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index a13c884..5a1f8b8 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -19,13 +19,13 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class MainWindowViewModel : ViewModelBase { // TODO consider moving editor-related functionality to a separate ViewModel. - internal TagDatabase Database; + internal TagDatabase? Database; - [ObservableProperty] private HierarchyTreeViewModel _hierarchyTreeViewModel; + [ObservableProperty] private HierarchyTreeViewModel? _hierarchyTreeViewModel; - [ObservableProperty] private SearchViewModel _searchViewModel; + [ObservableProperty] private SearchViewModel? _searchViewModel; - [ObservableProperty] private bool _isDbLoaded; + [ObservableProperty] private bool _isDbEnabled; private bool _isSwitching; @@ -41,8 +41,8 @@ public partial class MainWindowViewModel : ViewModelBase public int TotalTags => this.Database?.Tags.Count ?? 0; public string WindowTitle => - this.IsDbLoaded - ? string.Format(Resources.TitleWithDatabase, this.Database.Name) + this.IsDbEnabled + ? string.Format(Resources.TitleWithDatabase, this.Database?.Name) : Resources.Title; public TagItemViewModel? SelectedTag @@ -51,6 +51,7 @@ public partial class MainWindowViewModel : ViewModelBase set { if (this._selectedTag == value || this._isSwitching) return; + if (this._isDbEnabled == false && value == null) this._selectedTag = value; if (this._selectedTag != null && this.UnsavedChanges) { _ = this.HandleTagSwitchAsync(this._selectedTag, value); @@ -58,7 +59,7 @@ public partial class MainWindowViewModel : ViewModelBase else { this._selectedTag = value; - this.HierarchyTreeViewModel.SelectedTag = value; + this.HierarchyTreeViewModel?.SelectedTag = value; this._selectedTag?.BeginEdit(); this.UnsavedChanges = false; this.OnPropertyChanged(); @@ -68,29 +69,64 @@ public partial class MainWindowViewModel : ViewModelBase public async Task CreateNewDatabase(string filePath) { - this.IsDbLoaded = false; - TagDatabase db = new(); - db.InitialisationComplete += this.TagDatabase_OnInitalisationComplete; - // overwrite is set to true here for now since the OS should handle the overwrite request. - // will need to remove once Terminal.Gui is replaced. - await db.CreateAsync(filePath, true); + if (this.Database != null) + this.UninitialiseDatabase(); + + try + { + TagDatabase db = new(); + db.InitialisationComplete += this.TagDatabase_OnInitalisationComplete; + // overwrite is set to true here for now since the OS should handle the overwrite request. + // will need to remove once Terminal.Gui is fully replaced. + await Task.Run(() => db.CreateAsync(filePath, true)); + } + catch (Exception ex) + { + this.UninitialiseDatabase(); + this.ShowErrorDialog(ex.Message); + } + } public async Task ExportAsync(string path) { - var exporter = PickExporterFromFileExt(path); - this.StatusBlockText = "Exporting..."; - var exportContent = exporter.ExportDatabase(this.Database); - await File.WriteAllTextAsync(path, exportContent); - this.StatusBlockText = "Export complete."; + this.IsDbEnabled = false; + try + { + if (this.Database is null || string.IsNullOrWhiteSpace(path)) return; + + var exporter = PickExporterFromFileExt(path); + this.StatusBlockText = Resources.StatusBlockExportInProgress; + var exportContent = await Task.Run(() => exporter.ExportDatabase(this.Database!)); + await File.WriteAllTextAsync(path, exportContent); + this.IsDbEnabled = true; + this.StatusBlockText = string.Format(Resources.StatusBlockExportSuccessful, path); + } + catch (Exception ex) + { + this.IsDbEnabled = true; + this.ShowErrorDialog(ex.Message); + } + } public async Task LoadDatabase(string filePath) { - this.IsDbLoaded = false; - TagDatabase db = new(); - db.InitialisationComplete += this.TagDatabase_OnInitalisationComplete; - await db.LoadAsync(filePath); + try + { + if (this.Database != null) + this.UninitialiseDatabase(); + + TagDatabase db = new(); + db.InitialisationComplete += this.TagDatabase_OnInitalisationComplete; + await Task.Run(() => db.LoadAsync(filePath)); + } + catch (Exception ex) + { + this.UninitialiseDatabase(); + this.ShowErrorDialog(ex.Message); + } + } public void NewTag() @@ -99,7 +135,8 @@ public partial class MainWindowViewModel : ViewModelBase new Tag { Name = string.Empty, - IsTopLevel = true + IsTopLevel = true, + TagBindings = this.Database!.DefaultTagBindings } ); this.SelectedTag.BeginEdit(); @@ -110,10 +147,27 @@ public partial class MainWindowViewModel : ViewModelBase { if (this.SelectedTag is null || this.Database is null) return; - this.SelectedTag.CommitEdit(); - await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); - this.StatusBlockText = $"Successfully saved tag {this.SelectedTag.Name}"; - this.UnsavedChanges = false; + try + { + this.SelectedTag.CommitEdit(); + await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); + this.StatusBlockText = string.Format(Assets.Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); + this.UnsavedChanges = false; + } + catch (Exception ex) + { + this.ShowErrorDialog(ex.Message); + } + } + + public void ShowErrorDialog(string message) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return; + + var mainWindow = desktop.MainWindow; + var error = new ErrorDialogViewModel(message); + error.ShowDialog(mainWindow); } public async Task ShowNullableBoolDialog(Window dialog) @@ -122,13 +176,13 @@ public partial class MainWindowViewModel : ViewModelBase return null; var mainWindow = desktop.MainWindow; - var result = await dialog.ShowDialog(mainWindow); + var result = await dialog.ShowDialog(mainWindow!); return result; } public void StartSearch(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { - this.SearchViewModel.Search(searchQuery, mode, searchAliases); + this.SearchViewModel?.Search(searchQuery, mode, searchAliases); } public async Task StartTagDeletion() @@ -153,10 +207,18 @@ public partial class MainWindowViewModel : ViewModelBase private async Task DeleteSelectedTagAsync() { if (this.SelectedTag is null || this.Database is null) return; - await this.Database.DeleteTag(this.SelectedTag.Tag.Id); - this._selectedTag = null; - this.HierarchyTreeViewModel.SelectedTag = null; - this.OnPropertyChanged(nameof(this.SelectedTag)); + try + { + await this.Database.DeleteTag(this.SelectedTag.Tag.Id); + this._selectedTag = null; + this.HierarchyTreeViewModel?.SelectedTag = null; + this.OnPropertyChanged(nameof(this.SelectedTag)); + } + catch (Exception ex) + { + this.ShowErrorDialog(ex.Message); + } + } private async Task HandleTagSwitchAsync(TagItemViewModel? oldTag, TagItemViewModel? newTag) @@ -169,7 +231,7 @@ public partial class MainWindowViewModel : ViewModelBase if (result == null) { this._selectedTag = oldTag; - this.HierarchyTreeViewModel.SelectedTag = oldTag; + this.HierarchyTreeViewModel?.SelectedTag = oldTag; this.OnPropertyChanged(nameof(this.SelectedTag)); return; } @@ -178,7 +240,7 @@ public partial class MainWindowViewModel : ViewModelBase await this.SaveSelectedTagAsync(); this._selectedTag = newTag; - this.HierarchyTreeViewModel.SelectedTag = newTag; + this.HierarchyTreeViewModel?.SelectedTag = newTag; this._selectedTag?.BeginEdit(); this.OnPropertyChanged(nameof(this.SelectedTag)); this.UnsavedChanges = false; @@ -189,32 +251,42 @@ public partial class MainWindowViewModel : ViewModelBase } } - private void TagDatabase_OnInitalisationComplete(object sender, EventArgs e) + private void TagDatabase_OnInitalisationComplete(object? sender, EventArgs e) { if (sender is not TagDatabase db) return; - Dispatcher.UIThread.Post(async () => + Dispatcher.UIThread.Post(async void () => { - (this.HierarchyTreeViewModel as IDisposable)?.Dispose(); - (this.SearchViewModel as IDisposable)?.Dispose(); + try + { + (this.HierarchyTreeViewModel as IDisposable)?.Dispose(); + (this.SearchViewModel as IDisposable)?.Dispose(); - if (this.Database != null) + if (this.Database != null) + { + this.Database.TagAdded -= this.TagDatabase_TagAdded; + this.Database.TagDeleted -= this.TagDatabase_TagDeleted; + } + + this.Database = db; + this.SelectedTag = null; + this.IsDbEnabled = true; + this.HierarchyTreeViewModel = new HierarchyTreeViewModel(this); + this.SearchViewModel = new SearchViewModel(this); + this.Database.TagAdded += this.TagDatabase_TagAdded; + this.Database.TagDeleted += this.TagDatabase_TagDeleted; + await this.HierarchyTreeViewModel.InitializeAsync(); + this.OnPropertyChanged(nameof(this.TotalTags)); + this.OnPropertyChanged(nameof(this.WindowTitle)); + this.Database.InitialisationComplete -= this.TagDatabase_OnInitalisationComplete; + + this.UnsavedChanges = false; + this.StatusBlockText = string.Format(Resources.StatusBlockDbLoadSuccessful, this.Database.Name); + } + catch (Exception ex) { - this.Database.TagAdded -= this.TagDatabase_TagAdded; - this.Database.TagDeleted -= this.TagDatabase_TagDeleted; + this.UninitialiseDatabase(); + this.ShowErrorDialog(ex.Message); } - - this.Database = db; - this.IsDbLoaded = true; - this.HierarchyTreeViewModel = new HierarchyTreeViewModel(this); - this.SearchViewModel = new SearchViewModel(this); - this.Database.TagAdded += this.TagDatabase_TagAdded; - this.Database.TagDeleted += this.TagDatabase_TagDeleted; - await this.HierarchyTreeViewModel.InitializeAsync(); - this.OnPropertyChanged(nameof(this.TotalTags)); - this.OnPropertyChanged(nameof(this.WindowTitle)); - this.Database.InitialisationComplete -= this.TagDatabase_OnInitalisationComplete; - this.UnsavedChanges = false; - this.StatusBlockText = string.Format(Resources.StatusBlockDbLoadSuccessful, this.Database.Name); }); Debug.WriteLine($"Database loaded on UI - name: {db.Name}, version: {db.Version}"); } @@ -228,4 +300,17 @@ public partial class MainWindowViewModel : ViewModelBase { this.OnPropertyChanged(nameof(this.TotalTags)); } + + private void UninitialiseDatabase() + { + if (this.Database == null) return; + this.Database.TagAdded -= this.TagDatabase_TagAdded; + this.Database.TagDeleted -= this.TagDatabase_TagDeleted; + this.Database = null; + this.IsDbEnabled = false; + this.HierarchyTreeViewModel = null; + this.SearchViewModel = null; + this.OnPropertyChanged(nameof(this.TotalTags)); + this.OnPropertyChanged(nameof(this.WindowTitle)); + } } \ No newline at end of file diff --git a/UI/ViewModels/SearchViewModel.cs b/UI/ViewModels/SearchViewModel.cs index d03597c..981d172 100644 --- a/UI/ViewModels/SearchViewModel.cs +++ b/UI/ViewModels/SearchViewModel.cs @@ -13,40 +13,38 @@ public partial class SearchViewModel : ViewModelBase, IDisposable public SearchViewModel(MainWindowViewModel mainWindow) { this.mainWindow = mainWindow; - mainWindow.Database.TagDeleted += TagDatabase_OnTagDeleted; + mainWindow.Database.TagDeleted += this.TagDatabase_OnTagDeleted; } [ObservableProperty] private ObservableCollection _searchResults = []; [ObservableProperty] private TagItemViewModel? _selectedSearchResult; - public void Dispose() => - mainWindow.Database.TagDeleted -= TagDatabase_OnTagDeleted; + public void Dispose() => this.mainWindow.Database?.TagDeleted -= this.TagDatabase_OnTagDeleted; public void Search(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { - if (string.IsNullOrWhiteSpace(searchQuery)) return; + if (this.mainWindow.Database == null || string.IsNullOrWhiteSpace(searchQuery)) return; var results = searchAliases - ? mainWindow.Database.SearchWithAliases(searchQuery, mode) - : mainWindow.Database.Search(searchQuery, mode); + ? this.mainWindow.Database.SearchWithAliases(searchQuery, mode) + : this.mainWindow.Database.Search(searchQuery, mode); string statusBlockString = string.Empty; this.SearchResults.Clear(); if (results.Count == 0) { // todo resx - mainWindow.StatusBlockText = Assets.Resources.SearchNoResultsFound; + this.mainWindow.StatusBlockText = Assets.Resources.SearchNoResultsFound; return; } - results.Select(tag => new TagItemViewModel(tag, id => - mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) + results.Select(tag => new TagItemViewModel(tag, id => this.mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) .OrderBy(tag => tag.Name) .ToList() .ForEach(this.SearchResults.Add); - mainWindow.StatusBlockText = results.Count > 1 + this.mainWindow.StatusBlockText = results.Count > 1 ? string.Format(Assets.Resources.SearchMultipleResultsFound, results.Count) : Assets.Resources.SearchOneResultFound; @@ -55,12 +53,13 @@ public partial class SearchViewModel : ViewModelBase, IDisposable partial void OnSelectedSearchResultChanged(TagItemViewModel? value) { if (value is null) return; - mainWindow.SelectedTag = value; + this.mainWindow.SelectedTag = value; } private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) deletedTag) { - var deletedTagItemVM = this.SearchResults.First(item => item.Tag.Id == deletedTag.id); - this.SearchResults.Remove(deletedTagItemVM); + var deletedTagItemVm = this.SearchResults.FirstOrDefault(item => item.Tag.Id == deletedTag.id); + if (deletedTagItemVm is null) return; + this.SearchResults.Remove(deletedTagItemVm); } } \ No newline at end of file diff --git a/UI/ViewModels/TagItemViewModel.cs b/UI/ViewModels/TagItemViewModel.cs index c669bb4..7f3d85e 100644 --- a/UI/ViewModels/TagItemViewModel.cs +++ b/UI/ViewModels/TagItemViewModel.cs @@ -67,6 +67,7 @@ public partial class TagItemViewModel(Tag tag, Func? getNameById = public void CommitEdit() { + this.Validate(); this.Tag.Name = this.EditingName; this.Tag.Parents = !string.IsNullOrWhiteSpace(this.EditingParents) ? this.EditingParents.Split(';', StringSplitOptions.RemoveEmptyEntries | @@ -129,7 +130,20 @@ public partial class TagItemViewModel(Tag tag, Func? getNameById = { base.OnPropertyChanged(e); - if (!this._isInitialising && e.PropertyName.StartsWith("Editing")) + if (!this._isInitialising && e.PropertyName!.StartsWith("Editing")) this.UserEditedTag?.Invoke(this, EventArgs.Empty); } + + private void Validate() + { + List parentNames = this.EditingParents.Split(';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + if (!this.EditingIsTopLevel && parentNames.Count == 0) + throw new InvalidOperationException(Assets.Resources.ErrorOrphanTagAttempt); + + if (parentNames.Contains(this.Name)) + throw new InvalidOperationException(Assets.Resources.ErrorSelfParentAttempt); + } } \ No newline at end of file diff --git a/UI/Views/AboutWindow.axaml.cs b/UI/Views/AboutWindow.axaml.cs index 36f2f5e..33c4383 100644 --- a/UI/Views/AboutWindow.axaml.cs +++ b/UI/Views/AboutWindow.axaml.cs @@ -13,8 +13,5 @@ public partial class AboutWindow : Window public Uri ProjectUri { get; } = new(Assets.Resources.URL); - public void OnOKClick(object? sender, RoutedEventArgs e) - { - this.Close(); - } + public void OnOKClick(object? sender, RoutedEventArgs e) => this.Close(); } \ No newline at end of file diff --git a/UI/Views/DeleteTagDialog.axaml.cs b/UI/Views/DeleteTagDialog.axaml.cs index 1f442c7..9e3ceed 100644 --- a/UI/Views/DeleteTagDialog.axaml.cs +++ b/UI/Views/DeleteTagDialog.axaml.cs @@ -10,13 +10,7 @@ public partial class DeleteTagDialog : Window this.InitializeComponent(); } - public void ButtonNo_Click(object? sender, RoutedEventArgs e) - { - this.Close(false); - } + public void ButtonNo_Click(object? sender, RoutedEventArgs e) => this.Close(false); - public void ButtonYes_Click(object? sender, RoutedEventArgs e) - { - this.Close(true); - } + public void ButtonYes_Click(object? sender, RoutedEventArgs e) => this.Close(true); } \ No newline at end of file diff --git a/UI/Views/ErrorDialog.axaml b/UI/Views/ErrorDialog.axaml new file mode 100644 index 0000000..227985b --- /dev/null +++ b/UI/Views/ErrorDialog.axaml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/UI/Views/MainWindow.axaml.cs b/UI/Views/MainWindow.axaml.cs index 1ccaefa..d9b7bf7 100644 --- a/UI/Views/MainWindow.axaml.cs +++ b/UI/Views/MainWindow.axaml.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -20,50 +21,90 @@ public partial class MainWindow : Window public void ButtonAdd_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.NewTag(); + if (this.ViewModel?.SelectedTag is null) return; + this.ViewModel.NewTag(); } public void ButtonCancel_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.SelectedTag.BeginEdit(); + this.ViewModel?.SelectedTag?.BeginEdit(); } public void ButtonDelete_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.StartTagDeletion(); + try + { + if (this.ViewModel?.Database is null) return; + this.ViewModel?.StartTagDeletion(); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } - + public async void ButtonSave_Click(object? sender, RoutedEventArgs e) { - await this.ViewModel?.SaveSelectedTagAsync(); + try + { + if (this.ViewModel?.SelectedTag is null) return; + await this.ViewModel.SaveSelectedTagAsync(); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public void ButtonSearch_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.StartSearch( - this.SearchTextBox.Text!, - (TagDatabaseSearchMode)this.SearchModeComboBox.SelectedIndex, - this.SearchAliasesCheckBox.IsChecked ?? false); + try + { + if (this.ViewModel?.Database is null) return; + this.ViewModel?.StartSearch( + this.SearchTextBox.Text!, + (TagDatabaseSearchMode)this.SearchModeComboBox.SelectedIndex, + this.SearchAliasesCheckBox.IsChecked ?? false); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } public void MenuItemDatabaseSettings_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.ShowDatabaseSettings(); + if (this.ViewModel?.Database == null) return; + this.ViewModel.ShowDatabaseSettings(); } + // Resharper disable once AsyncVoidEventHandlerMethod public async void MenuItemExport_Click(object? sender, RoutedEventArgs e) { - var file = await this.StorageProvider.SaveFilePickerAsync( - new FilePickerSaveOptions - { - Title = Assets.Resources.DialogTitleExportTagDatabase, - FileTypeChoices = [Common.MusicBeeTagHierarchy], - SuggestedFileName = this.ViewModel?.Database.Name - }); - if (file == null) return; - var path = file.TryGetLocalPath(); - if (path == null) return; - await this.ViewModel?.ExportAsync(path); + if (this.ViewModel?.Database == null) return; + try + { + var file = await this.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Assets.Resources.DialogTitleExportTagDatabase, + FileTypeChoices = [Common.MusicBeeTagHierarchy], + SuggestedFileName = this.ViewModel.Database.Name + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.ExportAsync(path); + } + catch (Exception ex) + { + this.ViewModel.IsDbEnabled = true; + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public void MenuItemImport_Click(object? sender, RoutedEventArgs e) @@ -73,32 +114,48 @@ public partial class MainWindow : Window public async void MenuItemNew_Click(object? sender, RoutedEventArgs e) { - if (this.ViewModel is null) return; - var file = await this.StorageProvider.SaveFilePickerAsync( - new FilePickerSaveOptions - { - Title = Assets.Resources.DialogTitleSaveDatabaseAs, - FileTypeChoices = [Common.TagDatabaseFileType] - }); - if (file == null) return; - var path = file.TryGetLocalPath(); - if (path == null) return; - await this.ViewModel.CreateNewDatabase(path); + try + { + if (this.ViewModel is null) return; + var file = await this.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Assets.Resources.DialogTitleSaveDatabaseAs, + FileTypeChoices = [Common.TagDatabaseFileType] + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.CreateNewDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public async void MenuItemOpen_Click(object? sender, RoutedEventArgs e) { - var files = await this.StorageProvider.OpenFilePickerAsync( - new FilePickerOpenOptions - { - AllowMultiple = false, - Title = Assets.Resources.DialogTitleOpenDatabase, - FileTypeFilter = [Common.TagDatabaseFileType] - }); - if (files.Count == 0) return; - var path = files[0].TryGetLocalPath(); - if (path == null) return; - await this.ViewModel?.LoadDatabase(path); + try + { + if (this.ViewModel is null) return; + var files = await this.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + AllowMultiple = false, + Title = Assets.Resources.DialogTitleOpenDatabase, + FileTypeFilter = [Common.TagDatabaseFileType] + }); + if (files.Count == 0) return; + var path = files[0].TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.LoadDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } @@ -121,25 +178,38 @@ public partial class MainWindow : Window public async void WindowClosing(object? sender, WindowClosingEventArgs e) { - if (this._userWantsToQuit) return; - if (this.ViewModel.UnsavedChanges) + try { - e.Cancel = true; - var result = await this.ViewModel.ShowNullableBoolDialog(new UnsavedChangesDialog()); - switch (result) + if (this._userWantsToQuit) return; + if (this.ViewModel is not null && this.ViewModel.UnsavedChanges) { - case true: - await this.ViewModel.SaveSelectedTagAsync(); - this._userWantsToQuit = true; - this.Close(); - break; - case false: - this._userWantsToQuit = true; - this.Close(); - break; - case null: - break; + e.Cancel = true; + var result = await this.ViewModel.ShowNullableBoolDialog(new UnsavedChangesDialog()); + switch (result) + { + case true: + await this.ViewModel.SaveSelectedTagAsync(); + this._userWantsToQuit = true; + this.Close(); + break; + case false: + this._userWantsToQuit = true; + this.Close(); + break; + case null: + break; + } } + else + { + this._userWantsToQuit = true; + this.Close(); + } + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); } } } \ No newline at end of file -- 2.51.2 From 3ee5ca43aa10a1bcf6b2d963ca7e2c04f3646b47 Mon Sep 17 00:00:00 2001 From: Flaky Date: Wed, 14 Jan 2026 15:39:03 +0000 Subject: [PATCH 37/37] cleanup before release --- .../TagDatabase/TagDatabase.DeleteMethods.cs | 2 +- Core/TagHierarchyManager.csproj | 1 - UI/ViewModels/ErrorDialogViewModel.cs | 5 +- UI/ViewModels/HierarchyTreeViewModel.cs | 13 +- UI/ViewModels/ImportDialogViewModel.cs | 13 +- UI/ViewModels/MainWindowViewModel.cs | 58 ++---- UI/ViewModels/SearchViewModel.cs | 22 +- UI/ViewModels/TagItemViewModel.cs | 8 +- UI/Views/Common.cs | 2 +- UI/Views/ImportDialog.axaml.cs | 26 ++- UI/Views/MainWindow.axaml | 20 +- UI/Views/MainWindow.axaml.cs | 188 ++++++++++++------ 12 files changed, 217 insertions(+), 141 deletions(-) diff --git a/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs b/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs index 9b4f7ea..d517dcc 100644 --- a/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs +++ b/Core/Models/TagDatabase/TagDatabase.DeleteMethods.cs @@ -69,7 +69,7 @@ partial class TagDatabase tag.Parents.Remove(targetTag.Name); }); - Tag cachedTag = this.Tags.FirstOrDefault(t => t.Id == targetTag.Id); + Tag? cachedTag = this.Tags.FirstOrDefault(t => t.Id == targetTag.Id); if (cachedTag != null) { this.Tags.Remove(cachedTag); diff --git a/Core/TagHierarchyManager.csproj b/Core/TagHierarchyManager.csproj index 9127e84..7dcc757 100644 --- a/Core/TagHierarchyManager.csproj +++ b/Core/TagHierarchyManager.csproj @@ -6,7 +6,6 @@ enable enable false - true true diff --git a/UI/ViewModels/ErrorDialogViewModel.cs b/UI/ViewModels/ErrorDialogViewModel.cs index f91d0cf..c2272d9 100644 --- a/UI/ViewModels/ErrorDialogViewModel.cs +++ b/UI/ViewModels/ErrorDialogViewModel.cs @@ -26,7 +26,10 @@ public partial class ErrorDialogViewModel : ViewModelBase DataContext = this, Title = Resources.ErrorDialogTitle }; + error.Closed += (_, _) => error.DataContext = null; + var ownerWindow = desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow; - error.ShowDialog(ownerWindow); + if (ownerWindow == null) error.Show(); + else error.ShowDialog(ownerWindow); } } \ No newline at end of file diff --git a/UI/ViewModels/HierarchyTreeViewModel.cs b/UI/ViewModels/HierarchyTreeViewModel.cs index b49967f..e33e6f9 100644 --- a/UI/ViewModels/HierarchyTreeViewModel.cs +++ b/UI/ViewModels/HierarchyTreeViewModel.cs @@ -48,12 +48,12 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable viewModel = new TagItemViewModel(tag, id => this._viewModelMap.Values.FirstOrDefault(v => v.Id == id)?.Name); this._viewModelMap[key] = viewModel; - viewModel.UserEditedTag += (s, e) => this._mainWindow.UnsavedChanges = true; + viewModel.UserEditedTag += this.OnUserEditedTag; } return viewModel; } - + partial void OnSelectedTagChanged(TagItemViewModel? value) { this._mainWindow.SelectedTag = value; @@ -67,6 +67,8 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable }); await this.SyncHierarchyAsync(); } + + private void OnUserEditedTag(object? sender, EventArgs e) => this._mainWindow.UnsavedChanges = true; private void SubscribeToEvents() { @@ -95,7 +97,6 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable } } - private async Task SyncHierarchyAsync() { if (this._mainWindow.Database is null) return; @@ -123,7 +124,11 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable this.SyncCollection(this.TopLevelTags, topLevelViewModels); var keysToRemove = this._viewModelMap.Keys.Where(k => !activeKeys.Contains(k)).ToList(); - foreach (var key in keysToRemove) this._viewModelMap.Remove(key); + foreach (var key in keysToRemove) + { + this._viewModelMap[key].UserEditedTag -= this.OnUserEditedTag; + this._viewModelMap.Remove(key); + } } private void SyncTagRecursive(TagItemViewModel parentVm, ILookup childrenLookup, diff --git a/UI/ViewModels/ImportDialogViewModel.cs b/UI/ViewModels/ImportDialogViewModel.cs index c09bc41..27e5599 100644 --- a/UI/ViewModels/ImportDialogViewModel.cs +++ b/UI/ViewModels/ImportDialogViewModel.cs @@ -5,24 +5,19 @@ using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.ViewModels; -public partial class ImportDialogViewModel : ViewModelBase +public partial class ImportDialogViewModel(MainWindowViewModel mainWindow) : ViewModelBase { [ObservableProperty] [NotifyPropertyChangedFor(nameof(VisibleDatabaseFilePath))] [NotifyPropertyChangedFor(nameof(BothFilesSelected))] - private string _databaseFilePath; + private string _databaseFilePath = string.Empty; [ObservableProperty] private string _importStatus = string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(VisibleTemplateFilePath))] [NotifyPropertyChangedFor(nameof(BothFilesSelected))] - private string _templateFilePath; - - public ImportDialogViewModel(MainWindowViewModel mainWindow) - { - this.MainWindow = mainWindow; - } + private string _templateFilePath = string.Empty; public event Action? RequestClose; @@ -35,7 +30,7 @@ public partial class ImportDialogViewModel : ViewModelBase public string VisibleTemplateFilePath => !string.IsNullOrWhiteSpace(this.TemplateFilePath) ? this.TemplateFilePath : Resources.ImportNoFilePicked; - private MainWindowViewModel MainWindow { get; } + private MainWindowViewModel MainWindow { get; } = mainWindow; public async Task InitiateImport() { diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index a14e0f2..92214ec 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -42,7 +42,7 @@ public partial class MainWindowViewModel : ViewModelBase public int TotalTags => this.Database?.Tags.Count ?? 0; public string WindowTitle => - this.IsDbEnabled + this.Database is not null ? string.Format(Resources.TitleWithDatabase, this.Database?.Name) : Resources.Title; @@ -52,7 +52,7 @@ public partial class MainWindowViewModel : ViewModelBase set { if (this._selectedTag == value || this._isSwitching) return; - if (!this.IsDbEnabled && value == null) this._selectedTag = value; + if (value == null) this._selectedTag = value; if (this._selectedTag != null && this.UnsavedChanges) { _ = this.HandleTagSwitchAsync(this._selectedTag, value); @@ -101,23 +101,14 @@ public partial class MainWindowViewModel : ViewModelBase public async Task ExportAsync(string path) { + if (this.Database is null || string.IsNullOrWhiteSpace(path)) return; this.IsDbEnabled = false; - try - { - if (this.Database is null || string.IsNullOrWhiteSpace(path)) return; - - var exporter = PickExporterFromFileExt(path); - this.StatusBlockText = Resources.StatusBlockExportInProgress; - var exportContent = await Task.Run(() => exporter.ExportDatabase(this.Database!)); - await File.WriteAllTextAsync(path, exportContent); - this.IsDbEnabled = true; - this.StatusBlockText = string.Format(Resources.StatusBlockExportSuccessful, path); - } - catch (Exception ex) - { - this.IsDbEnabled = true; - this.ShowErrorDialog(ex.Message); - } + var exporter = PickExporterFromFileExt(path); + this.StatusBlockText = Resources.StatusBlockExportInProgress; + var exportContent = await Task.Run(() => exporter.ExportDatabase(this.Database!)); + await File.WriteAllTextAsync(path, exportContent); + this.IsDbEnabled = true; + this.StatusBlockText = string.Format(Resources.StatusBlockExportSuccessful, path); } public async Task LoadDatabase(string filePath) @@ -153,19 +144,12 @@ public partial class MainWindowViewModel : ViewModelBase public async Task SaveSelectedTagAsync() { if (this.SelectedTag is null || this.Database is null) return; - - try - { - this.SelectedTag.CommitEdit(); - await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); - this.SelectedTag.RefreshParentsString(); - this.StatusBlockText = string.Format(Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); - this.UnsavedChanges = false; - } - catch (Exception ex) - { - this.ShowErrorDialog(ex.Message); - } + + this.SelectedTag.CommitEdit(); + await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); + this.SelectedTag.RefreshParentsString(); + this.StatusBlockText = string.Format(Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); + this.UnsavedChanges = false; } public void ShowDatabaseSettings() @@ -181,12 +165,6 @@ public partial class MainWindowViewModel : ViewModelBase dialog.ShowDialog(desktop.MainWindow!); } - public void ShowErrorDialog(string message) - { - var error = new ErrorDialogViewModel(message); - error.ShowDialog(); - } - public void ShowImportDialog() { if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) @@ -331,6 +309,12 @@ public partial class MainWindowViewModel : ViewModelBase }); Debug.WriteLine($"Database loaded on UI - name: {db.Name}, version: {db.Version}"); } + + private void ShowErrorDialog(string message) + { + var error = new ErrorDialogViewModel(message); + error.ShowDialog(); + } private void TagDatabase_TagAdded(object? sender, Tag _) { diff --git a/UI/ViewModels/SearchViewModel.cs b/UI/ViewModels/SearchViewModel.cs index 7f6e957..e884cda 100644 --- a/UI/ViewModels/SearchViewModel.cs +++ b/UI/ViewModels/SearchViewModel.cs @@ -9,7 +9,7 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class SearchViewModel : ViewModelBase, IDisposable { - private readonly MainWindowViewModel mainWindow; + private readonly MainWindowViewModel _mainWindow; [ObservableProperty] private ObservableCollection _searchResults = []; @@ -17,34 +17,34 @@ public partial class SearchViewModel : ViewModelBase, IDisposable public SearchViewModel(MainWindowViewModel mainWindow) { - this.mainWindow = mainWindow; - mainWindow.Database.TagDeleted += this.TagDatabase_OnTagDeleted; + this._mainWindow = mainWindow; + mainWindow.Database?.TagDeleted += this.TagDatabase_OnTagDeleted; } - public void Dispose() => this.mainWindow.Database?.TagDeleted -= this.TagDatabase_OnTagDeleted; + public void Dispose() => this._mainWindow.Database?.TagDeleted -= this.TagDatabase_OnTagDeleted; public void Search(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { - if (this.mainWindow.Database == null || string.IsNullOrWhiteSpace(searchQuery)) return; + if (this._mainWindow.Database == null || string.IsNullOrWhiteSpace(searchQuery)) return; var results = searchAliases - ? this.mainWindow.Database.SearchWithAliases(searchQuery, mode) - : this.mainWindow.Database.Search(searchQuery, mode); + ? this._mainWindow.Database.SearchWithAliases(searchQuery, mode) + : this._mainWindow.Database.Search(searchQuery, mode); this.SearchResults.Clear(); if (results.Count == 0) { - this.mainWindow.StatusBlockText = Resources.SearchNoResultsFound; + this._mainWindow.StatusBlockText = Resources.SearchNoResultsFound; return; } results.Select(tag => - new TagItemViewModel(tag, id => this.mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) + new TagItemViewModel(tag, id => this._mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) .OrderBy(tag => tag.Name) .ToList() .ForEach(this.SearchResults.Add); - this.mainWindow.StatusBlockText = results.Count > 1 + this._mainWindow.StatusBlockText = results.Count > 1 ? string.Format(Resources.SearchMultipleResultsFound, results.Count) : Resources.SearchOneResultFound; } @@ -52,7 +52,7 @@ public partial class SearchViewModel : ViewModelBase, IDisposable partial void OnSelectedSearchResultChanged(TagItemViewModel? value) { if (value is null) return; - this.mainWindow.SelectedTag = value; + this._mainWindow.SelectedTag = value; } private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) deletedTag) diff --git a/UI/ViewModels/TagItemViewModel.cs b/UI/ViewModels/TagItemViewModel.cs index 78654dd..1a4cfa7 100644 --- a/UI/ViewModels/TagItemViewModel.cs +++ b/UI/ViewModels/TagItemViewModel.cs @@ -11,17 +11,17 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class TagItemViewModel(Tag tag, Func? getNameById = null) : ViewModelBase { - [ObservableProperty] private string _editingAliases; + [ObservableProperty] private string _editingAliases = string.Empty; [ObservableProperty] private bool _editingIsTopLevel; [ObservableProperty] private string _editingName = tag.Name; - [ObservableProperty] private string _editingNotes; + [ObservableProperty] private string _editingNotes = string.Empty; - [ObservableProperty] private string _editingParents; + [ObservableProperty] private string _editingParents = string.Empty; - [ObservableProperty] private string _editingTagBindings; + [ObservableProperty] private string _editingTagBindings = string.Empty; private bool _isInitialising; diff --git a/UI/Views/Common.cs b/UI/Views/Common.cs index 747d8ed..0aa25e2 100644 --- a/UI/Views/Common.cs +++ b/UI/Views/Common.cs @@ -3,7 +3,7 @@ using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.Views; -public class Common +public static class Common { public static FilePickerFileType MusicBeeTagHierarchy { get; } = new(Resources.FileFormatMusicBeeTagHierarchy) { diff --git a/UI/Views/ImportDialog.axaml.cs b/UI/Views/ImportDialog.axaml.cs index cd05bff..b2fb31c 100644 --- a/UI/Views/ImportDialog.axaml.cs +++ b/UI/Views/ImportDialog.axaml.cs @@ -11,10 +11,17 @@ public partial class ImportDialog : Window public ImportDialog() { this.InitializeComponent(); - this.DataContextChanged += (s, e) => + + this.DataContextChanged += (_, _) => { if (this.ViewModel != null) this.ViewModel.RequestClose += this.Close; }; + + this.Unloaded += (_, _) => + { + if (this.ViewModel != null) + this.ViewModel.RequestClose -= this.Close; + }; } private ImportDialogViewModel? ViewModel => this.DataContext as ImportDialogViewModel; @@ -68,6 +75,19 @@ public partial class ImportDialog : Window public void ButtonCancel_Click(object? sender, RoutedEventArgs e) => this.Close(); - public async void ButtonImport_Click(object? sender, RoutedEventArgs e) => - await this.ViewModel?.InitiateImport(); + public async void ButtonImport_Click(object? sender, RoutedEventArgs e) + { + try + { + if (this.ViewModel?.DatabaseFilePath == null || this.ViewModel?.TemplateFilePath == null || + this.ViewModel is null) return; + await this.ViewModel.InitiateImport(); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } + } \ No newline at end of file diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml index b152251..3db912f 100644 --- a/UI/Views/MainWindow.axaml +++ b/UI/Views/MainWindow.axaml @@ -46,8 +46,8 @@ + ItemsSource="{Binding HierarchyTreeViewModel.TopLevelTags, FallbackValue={x:Null}}" + SelectedItem="{Binding HierarchyTreeViewModel.SelectedTag, Mode=TwoWay, FallbackValue={x:Null}}"> @@ -106,30 +106,30 @@ RowDefinitions="Auto, Auto, Auto, Auto, *, Auto" Margin="5" IsEnabled="{Binding SelectedTag, Converter={x:Static ObjectConverters.IsNotNull}}"> - - - - - - diff --git a/UI/Views/MainWindow.axaml.cs b/UI/Views/MainWindow.axaml.cs index 1ccaefa..d9b7bf7 100644 --- a/UI/Views/MainWindow.axaml.cs +++ b/UI/Views/MainWindow.axaml.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -20,50 +21,90 @@ public partial class MainWindow : Window public void ButtonAdd_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.NewTag(); + if (this.ViewModel?.SelectedTag is null) return; + this.ViewModel.NewTag(); } public void ButtonCancel_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.SelectedTag.BeginEdit(); + this.ViewModel?.SelectedTag?.BeginEdit(); } public void ButtonDelete_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.StartTagDeletion(); + try + { + if (this.ViewModel?.Database is null) return; + this.ViewModel?.StartTagDeletion(); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } - + public async void ButtonSave_Click(object? sender, RoutedEventArgs e) { - await this.ViewModel?.SaveSelectedTagAsync(); + try + { + if (this.ViewModel?.SelectedTag is null) return; + await this.ViewModel.SaveSelectedTagAsync(); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public void ButtonSearch_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.StartSearch( - this.SearchTextBox.Text!, - (TagDatabaseSearchMode)this.SearchModeComboBox.SelectedIndex, - this.SearchAliasesCheckBox.IsChecked ?? false); + try + { + if (this.ViewModel?.Database is null) return; + this.ViewModel?.StartSearch( + this.SearchTextBox.Text!, + (TagDatabaseSearchMode)this.SearchModeComboBox.SelectedIndex, + this.SearchAliasesCheckBox.IsChecked ?? false); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } public void MenuItemDatabaseSettings_Click(object? sender, RoutedEventArgs e) { - this.ViewModel?.ShowDatabaseSettings(); + if (this.ViewModel?.Database == null) return; + this.ViewModel.ShowDatabaseSettings(); } + // Resharper disable once AsyncVoidEventHandlerMethod public async void MenuItemExport_Click(object? sender, RoutedEventArgs e) { - var file = await this.StorageProvider.SaveFilePickerAsync( - new FilePickerSaveOptions - { - Title = Assets.Resources.DialogTitleExportTagDatabase, - FileTypeChoices = [Common.MusicBeeTagHierarchy], - SuggestedFileName = this.ViewModel?.Database.Name - }); - if (file == null) return; - var path = file.TryGetLocalPath(); - if (path == null) return; - await this.ViewModel?.ExportAsync(path); + if (this.ViewModel?.Database == null) return; + try + { + var file = await this.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Assets.Resources.DialogTitleExportTagDatabase, + FileTypeChoices = [Common.MusicBeeTagHierarchy], + SuggestedFileName = this.ViewModel.Database.Name + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.ExportAsync(path); + } + catch (Exception ex) + { + this.ViewModel.IsDbEnabled = true; + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public void MenuItemImport_Click(object? sender, RoutedEventArgs e) @@ -73,32 +114,48 @@ public partial class MainWindow : Window public async void MenuItemNew_Click(object? sender, RoutedEventArgs e) { - if (this.ViewModel is null) return; - var file = await this.StorageProvider.SaveFilePickerAsync( - new FilePickerSaveOptions - { - Title = Assets.Resources.DialogTitleSaveDatabaseAs, - FileTypeChoices = [Common.TagDatabaseFileType] - }); - if (file == null) return; - var path = file.TryGetLocalPath(); - if (path == null) return; - await this.ViewModel.CreateNewDatabase(path); + try + { + if (this.ViewModel is null) return; + var file = await this.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Assets.Resources.DialogTitleSaveDatabaseAs, + FileTypeChoices = [Common.TagDatabaseFileType] + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.CreateNewDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } public async void MenuItemOpen_Click(object? sender, RoutedEventArgs e) { - var files = await this.StorageProvider.OpenFilePickerAsync( - new FilePickerOpenOptions - { - AllowMultiple = false, - Title = Assets.Resources.DialogTitleOpenDatabase, - FileTypeFilter = [Common.TagDatabaseFileType] - }); - if (files.Count == 0) return; - var path = files[0].TryGetLocalPath(); - if (path == null) return; - await this.ViewModel?.LoadDatabase(path); + try + { + if (this.ViewModel is null) return; + var files = await this.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + AllowMultiple = false, + Title = Assets.Resources.DialogTitleOpenDatabase, + FileTypeFilter = [Common.TagDatabaseFileType] + }); + if (files.Count == 0) return; + var path = files[0].TryGetLocalPath(); + if (path == null) return; + await this.ViewModel.LoadDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } @@ -121,25 +178,38 @@ public partial class MainWindow : Window public async void WindowClosing(object? sender, WindowClosingEventArgs e) { - if (this._userWantsToQuit) return; - if (this.ViewModel.UnsavedChanges) + try { - e.Cancel = true; - var result = await this.ViewModel.ShowNullableBoolDialog(new UnsavedChangesDialog()); - switch (result) + if (this._userWantsToQuit) return; + if (this.ViewModel is not null && this.ViewModel.UnsavedChanges) { - case true: - await this.ViewModel.SaveSelectedTagAsync(); - this._userWantsToQuit = true; - this.Close(); - break; - case false: - this._userWantsToQuit = true; - this.Close(); - break; - case null: - break; + e.Cancel = true; + var result = await this.ViewModel.ShowNullableBoolDialog(new UnsavedChangesDialog()); + switch (result) + { + case true: + await this.ViewModel.SaveSelectedTagAsync(); + this._userWantsToQuit = true; + this.Close(); + break; + case false: + this._userWantsToQuit = true; + this.Close(); + break; + case null: + break; + } } + else + { + this._userWantsToQuit = true; + this.Close(); + } + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); } } } \ No newline at end of file