diff --git a/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs b/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs index 7e6b5e8..a4cc7ce 100644 --- a/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs +++ b/Core/Importers/MusicBeeTagHierarchyImporter/MusicBeeTagHierarchyImporter.cs @@ -11,6 +11,8 @@ public partial class MusicBeeTagHierarchyImporter : Importer private const int IndentSize = 4; // MusicBee is strict about having an indent size of 4 spaces. private const string TagBindingSeparator = "::"; + private static readonly string[] CommentSymbols = [";", "//"]; + /// /// Initializes a new instance of the class. /// @@ -63,7 +65,10 @@ public partial class MusicBeeTagHierarchyImporter : Importer private static bool IsEmptyOrComment(string line) { - return string.IsNullOrEmpty(line) || string.IsNullOrWhiteSpace(line) || line.StartsWith(CommentSymbol); + if (CommentSymbols.Any(symbol => line.StartsWith(symbol, StringComparison.Ordinal))) + return true; + + return string.IsNullOrEmpty(line) || string.IsNullOrWhiteSpace(line); } private static void UpdateParentStack(List parentStack, TagHierarchyLine currentLine, int previousIndent, diff --git a/UI/App.axaml.cs b/UI/App.axaml.cs index 191b2e0..41733d7 100644 --- a/UI/App.axaml.cs +++ b/UI/App.axaml.cs @@ -1,16 +1,17 @@ +using System.Linq; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; using Avalonia.Data.Core.Plugins; -using System.Linq; using Avalonia.Markup.Xaml; using TagHierarchyManager.UI.ViewModels; using TagHierarchyManager.UI.Views; namespace TagHierarchyManager.UI; -public partial class App : Application +public class App : Application { + private TagDatabaseService? TagDatabaseService { get; set; } + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -18,14 +19,17 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (this.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); + this.DisableAvaloniaDataAnnotationValidation(); + + this.TagDatabaseService = new TagDatabaseService(); + desktop.MainWindow = new MainWindow { - DataContext = new MainWindowViewModel(), + DataContext = new MainWindowViewModel(this.TagDatabaseService) }; } @@ -39,9 +43,6 @@ public partial class App : Application BindingPlugins.DataValidators.OfType().ToArray(); // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } + foreach (var plugin in dataValidationPluginsToRemove) BindingPlugins.DataValidators.Remove(plugin); } } \ No newline at end of file diff --git a/UI/Assets/Resources.resx b/UI/Assets/Resources.resx index 1cb8ef6..9f7be3d 100644 --- a/UI/Assets/Resources.resx +++ b/UI/Assets/Resources.resx @@ -256,10 +256,10 @@ Successfully added {0} tags. - - - Add multiple tags... + + You have unsaved changes to the currently loaded tag. Are you sure you want to cancel? + \ No newline at end of file diff --git a/UI/TagDatabaseService.cs b/UI/TagDatabaseService.cs new file mode 100644 index 0000000..98ab242 --- /dev/null +++ b/UI/TagDatabaseService.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using TagHierarchyManager.Common; +using TagHierarchyManager.Exporters; +using TagHierarchyManager.Importers; +using TagHierarchyManager.Models; +using TagHierarchyManager.UI.Assets; + +namespace TagHierarchyManager.UI; + +public class TagDatabaseService : ObservableObject +{ + public event EventHandler DatabaseClosing; + public event EventHandler InitialisationComplete; + + public event EventHandler TagAdded; + public event EventHandler<(int id, string name)> TagDeleted; + public event EventHandler> TagsAdded; + public event EventHandler TagUpdated; + + public string DatabaseName => this.Database?.Name ?? string.Empty; + public int DatabaseVersion => this.Database?.Version ?? 0; + public List DefaultTagBindings => this.Database.DefaultTagBindings; + + public bool IsDatabaseOpen => this.Database != null; + + public int TagCount => this.Database?.Tags.Count ?? 0; + public int TagRelationshipCount => this.Database?.GetTagRelationshipCount() ?? 0; + + private TagDatabase? Database { get; set; } + + public void CloseDatabase() + { + if (this.Database == null) return; + this.DatabaseClosing.Invoke(this, EventArgs.Empty); + this.Database = null; + this.UnsubscribeFromEvents(); + this.NotifyDatabasePropertiesChanged(); + } + + public async Task CreateNewDatabase(string filePath, string? templateFilePath = null) + { + if (this.Database != null) + this.CloseDatabase(); + + Dictionary? tagsToImport = null; + TagDatabase db = new(); + db.InitialisationComplete += this.TagDatabase_OnInitialisationComplete; + + try + { + if (string.IsNullOrWhiteSpace(filePath)) return; + + if (templateFilePath is not null && File.Exists(templateFilePath)) + { + var importer = this.PickImporterFromFileExt(templateFilePath); + tagsToImport = await importer.ImportFromFileAsync(templateFilePath); + } + + // 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, tagsToImport)); + } + catch (Exception ex) + { + db.InitialisationComplete -= this.TagDatabase_OnInitialisationComplete; + throw; + } + } + + public async Task DeleteTag(int id) + { + if (this.Database is null) return; + await Task.Run(() => this.Database.DeleteTag(id)); + } + + public async Task ExportAsync(string path) + { + if (!this.IsDatabaseOpen || string.IsNullOrWhiteSpace(path)) return; + var exporter = PickExporterFromFileExt(path); + var exportContent = await Task.Run(() => exporter.ExportDatabase(this.Database!)); + await File.WriteAllTextAsync(path, exportContent); + } + + public List GetAllTagChildren(int id) + { + return this.Database is not null + ? this.Database.Tags.Where(t => t.ParentIds.Contains(id)).OrderBy(t => t.Name).ToList() + : []; + } + + public List GetAllTags(bool topLevelOnly = false) + { + if (this.Database is null) return []; + + return topLevelOnly + ? this.Database.Tags.Where(t => t.IsTopLevel).OrderBy(t => t.Name).ToList() + : this.Database.Tags.OrderBy(t => t.Name).ToList(); + } + + public List GetParentNamesByIds(List ids) + { + return ids.Select(id => this.Database?.Tags.FirstOrDefault(t => t.Id == id)) + .Where(tag => tag is not null) + .Select(tag => tag!.Name) + .ToList(); + } + + public async Task LoadDatabase(string filePath) + { + try + { + if (this.Database != null) + this.CloseDatabase(); + + TagDatabase db = new(); + db.InitialisationComplete += this.TagDatabase_OnInitialisationComplete; + await Task.Run(() => db.LoadAsync(filePath)); + } + catch (Exception ex) + { + // this.ShowErrorDialog(ex.Message); + } + } + + public List SearchTags(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) + { + if (this.Database is null) return []; + return searchAliases + ? this.Database.SearchWithAliases(searchQuery, mode) + : this.Database.Search(searchQuery, mode); + } + + public void SetDefaultTagBindings(string input) + { + this.Database?.DefaultTagBindings = input.Split(";", + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + this.NotifyDatabasePropertiesChanged(); + } + + public async Task WriteTagsToDatabase(List tags) + { + if (this.Database is null) return; + await this.Database.WriteTagsToDatabase(tags); + } + + private static IExporter PickExporterFromFileExt(string path) + { + var 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 void NotifyDatabasePropertiesChanged() + { + Dispatcher.UIThread.Post(() => + { + this.OnPropertyChanged(nameof(this.TagCount)); + this.OnPropertyChanged(nameof(this.DefaultTagBindings)); + this.OnPropertyChanged(nameof(this.DatabaseVersion)); + this.OnPropertyChanged(nameof(this.TagRelationshipCount)); + + this.OnPropertyChanged(nameof(this.IsDatabaseOpen)); + this.OnPropertyChanged(nameof(this.DatabaseName)); + }); + } + + private Importer PickImporterFromFileExt(string path) + { + var 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(string.Format(Resources.ErrorImportFileTypeNotSupported, fileExt)); + } + + private void SubscribeToEvents() + { + if (this.Database is null) return; + this.Database.TagAdded += this.TagDatabase_OnTagAdded; + this.Database.TagsAdded += this.TagDatabase_OnTagsAdded; + this.Database.TagUpdated += this.TagDatabase_OnTagUpdated; + this.Database.TagDeleted += this.TagDatabase_OnTagDeleted; + } + + // TODO this isn't activating and new database results in NullReferenceException. + private void TagDatabase_OnInitialisationComplete(object? sender, EventArgs e) + { + this.Database = sender as TagDatabase; + this.Database.InitialisationComplete -= this.TagDatabase_OnInitialisationComplete; + this.SubscribeToEvents(); + this.NotifyDatabasePropertiesChanged(); + this.InitialisationComplete.Invoke(this, e); + } + + private void TagDatabase_OnTagAdded(object? sender, Tag newTag) + { + this.TagAdded.Invoke(this, newTag); + this.NotifyDatabasePropertiesChanged(); + } + + private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) deletedTag) + { + this.TagDeleted.Invoke(this, deletedTag); + this.NotifyDatabasePropertiesChanged(); + } + + private void TagDatabase_OnTagsAdded(object? sender, List newTags) + { + this.TagsAdded.Invoke(this, newTags); + this.NotifyDatabasePropertiesChanged(); + } + + private void TagDatabase_OnTagUpdated(object? sender, Tag updatedTag) + { + this.TagUpdated.Invoke(this, updatedTag); + this.NotifyDatabasePropertiesChanged(); + } + + private void UnsubscribeFromEvents() + { + if (this.Database is null) return; + this.Database.TagAdded -= this.TagDatabase_OnTagAdded; + this.Database.TagsAdded -= this.TagDatabase_OnTagsAdded; + this.Database.TagUpdated -= this.TagDatabase_OnTagUpdated; + this.Database.TagDeleted -= this.TagDatabase_OnTagDeleted; + } +} \ No newline at end of file diff --git a/UI/TagHierarchyManager.UI.csproj b/UI/TagHierarchyManager.UI.csproj index fdf0889..de15469 100644 --- a/UI/TagHierarchyManager.UI.csproj +++ b/UI/TagHierarchyManager.UI.csproj @@ -20,6 +20,11 @@ True Resources.resx + + + UnsavedCancelDialog.axaml + Code + diff --git a/UI/ViewModels/BulkAddViewModel.cs b/UI/ViewModels/BulkAddViewModel.cs index 72708ec..a678849 100644 --- a/UI/ViewModels/BulkAddViewModel.cs +++ b/UI/ViewModels/BulkAddViewModel.cs @@ -12,21 +12,21 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class BulkAddViewModel : ViewModelBase { + private readonly MainWindowViewModel _mainWindow; [ObservableProperty] private ObservableCollection _tags = [new() { Name = "", IsTopLevel = false }]; [ObservableProperty] private string _windowTitle; public BulkAddViewModel(MainWindowViewModel mainWindow) { - this.MainWindow = mainWindow; + this._mainWindow = mainWindow; this.Tags.CollectionChanged += this.OnTagsCollectionChanged; - this.WindowTitle = string.Format("{0} - " + Resources.ButtonBulkAdd, this.MainWindow.Database?.Name); + this.WindowTitle = string.Format("{0} - " + Resources.ButtonBulkAdd, + this._mainWindow.TagDatabaseService.DatabaseName); } public bool CanSave => this.Tags.Count > 0; - private MainWindowViewModel MainWindow { get; } - public void AddTag() { this.Tags.Add(new TagRow()); @@ -39,7 +39,7 @@ public partial class BulkAddViewModel : ViewModelBase public async Task SaveTags() { - if (this.MainWindow.Database is null) return; + if (!this._mainWindow.TagDatabaseService.IsDatabaseOpen) return; var tags = this.Tags.Select(tagRow => { var tag = new Tag @@ -68,8 +68,8 @@ public partial class BulkAddViewModel : ViewModelBase }) .ToList(); - await this.MainWindow.Database.WriteTagsToDatabase(tags); - this.MainWindow.StatusBlockText = string.Format(Resources.StatusBlockBulkAddSuccess, tags.Count); + await this._mainWindow.TagDatabaseService.WriteTagsToDatabase(tags); + this._mainWindow.StatusBlockText = string.Format(Resources.StatusBlockBulkAddSuccess, tags.Count); } private void OnTagsCollectionChanged(object? sender, diff --git a/UI/ViewModels/DatabaseSettingsViewModel.cs b/UI/ViewModels/DatabaseSettingsViewModel.cs index 41c5650..30c7693 100644 --- a/UI/ViewModels/DatabaseSettingsViewModel.cs +++ b/UI/ViewModels/DatabaseSettingsViewModel.cs @@ -1,12 +1,12 @@ -using System; -using System.Linq; -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.ViewModels; public partial class DatabaseSettingsViewModel : ViewModelBase { + private readonly MainWindowViewModel _mainWindow; + [ObservableProperty] private string _databaseVersion; [ObservableProperty] private string _defaultTagBindings; [ObservableProperty] private string _tagRowsCount; @@ -15,21 +15,20 @@ public partial class DatabaseSettingsViewModel : ViewModelBase public DatabaseSettingsViewModel(MainWindowViewModel mainWindow) { - this.MainWindow = mainWindow; + this._mainWindow = mainWindow; - this.WindowTitle = mainWindow.Database!.Name; - this.DatabaseVersion = string.Format(Resources.DatabaseSettingsDbVersion, mainWindow.Database!.Version); - var tagRelationshipCount = mainWindow.Database!.GetTagRelationshipCount(); + this.WindowTitle = mainWindow.TagDatabaseService.DatabaseName; + this.DatabaseVersion = string.Format(Resources.DatabaseSettingsDbVersion, + mainWindow.TagDatabaseService.DatabaseVersion); + var tagRelationshipCount = mainWindow.TagDatabaseService.TagRelationshipCount; this.TagRowsCount = string.Format(Resources.DatabaseSettingsTagCount, - mainWindow.Database!.Tags.Count, tagRelationshipCount); - this.DefaultTagBindings = string.Join("; ", mainWindow.Database!.DefaultTagBindings); + mainWindow.TagDatabaseService.TagCount, tagRelationshipCount); + this.DefaultTagBindings = string.Join("; ", mainWindow.TagDatabaseService.DefaultTagBindings); } - private MainWindowViewModel MainWindow { get; } public void SaveSettings() { - this.MainWindow.Database!.DefaultTagBindings = this.DefaultTagBindings.Split(";", - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + this._mainWindow.TagDatabaseService.SetDefaultTagBindings(this.DefaultTagBindings); } } \ No newline at end of file diff --git a/UI/ViewModels/HierarchyTreeViewModel.cs b/UI/ViewModels/HierarchyTreeViewModel.cs index 24ca808..6c67958 100644 --- a/UI/ViewModels/HierarchyTreeViewModel.cs +++ b/UI/ViewModels/HierarchyTreeViewModel.cs @@ -24,41 +24,40 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable public HierarchyTreeViewModel(MainWindowViewModel mainWindow) { this._mainWindow = mainWindow; - this._getParentNamesById = parentIds => - parentIds.Select(id => this._mainWindow.Database?.Tags.FirstOrDefault(t => t.Id == id)) - .Where(tag => tag is not null) - .Select(tag => tag!.Name) - .ToList(); + this._getParentNamesById = mainWindow.TagDatabaseService.GetParentNamesByIds; this.SubscribeToEvents(); } public void Dispose() { - if (this._mainWindow.Database is null) return; + if (!this._mainWindow.TagDatabaseService.IsDatabaseOpen) return; - this._mainWindow.Database.TagUpdated -= this.TagDatabase_OnTagUpdated; - this._mainWindow.Database.TagAdded -= this.TagDatabase_OnTagAdded; - this._mainWindow.Database.TagDeleted -= this.TagDatabase_OnTagDeleted; - this._mainWindow.Database.TagsAdded -= this.TagDatabase_OnTagsAdded; + this._mainWindow.TagDatabaseService.TagUpdated -= this.TagDatabase_OnTagUpdated; + this._mainWindow.TagDatabaseService.TagAdded -= this.TagDatabase_OnTagAdded; + this._mainWindow.TagDatabaseService.TagDeleted -= this.TagDatabase_OnTagDeleted; + this._mainWindow.TagDatabaseService.TagsAdded -= this.TagDatabase_OnTagsAdded; GC.SuppressFinalize(this); } public async Task InitializeAsync() { - if (this._mainWindow.Database is null) return; + if (!this._mainWindow.TagDatabaseService.IsDatabaseOpen) return; this.ViewModelMap.Clear(); + this.TopLevelTagNodes.Clear(); await Task.Run(() => { - var topLevelTags = this._mainWindow.Database?.Tags.Where(t => t.IsTopLevel).ToList(); + var topLevelTags = this._mainWindow.TagDatabaseService.GetAllTags(true); var buildingTopLevelNodes = new List(); - foreach (var tagNode in topLevelTags!.Select(tag => new TagItemViewModel(tag, this._getParentNamesById))) + foreach (var tagNode in + topLevelTags!.Select(tag => new TagItemViewModel(tag, this._getParentNamesById))) { buildingTopLevelNodes.Add(tagNode); this.AddTagNodeToViewModelMap(tagNode); this.AddAllChildrenAsync(tagNode); } - this.TopLevelTagNodes = new ObservableCollection(buildingTopLevelNodes); + foreach (var tagNode in buildingTopLevelNodes) + this.TopLevelTagNodes.Add(tagNode); }); } @@ -72,7 +71,7 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable } var childTags = - this._mainWindow.Database?.Tags.Where(t => t.ParentIds.Contains(tag.Id)).OrderBy(t => t.Name).ToList(); + this._mainWindow.TagDatabaseService.GetAllTagChildren(tag.Id); if (childTags is null) return; foreach (var childNode in childTags.Select(child => @@ -138,7 +137,14 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable var newTopLevelTag = new TagItemViewModel(tag, this._getParentNamesById); // this does create considerable delay, would be nice to speed it up somehow. this.AddAllChildrenAsync(newTopLevelTag); - this.TopLevelTagNodes.Add(newTopLevelTag); + + var index = 0; + while (index < this.TopLevelTagNodes.Count + && string.Compare(this.TopLevelTagNodes[index].Name, newTopLevelTag.Name, + StringComparison.CurrentCultureIgnoreCase) < 0) + index++; + + this.TopLevelTagNodes.Insert(index, newTopLevelTag); this.AddTagNodeToViewModelMap(newTopLevelTag); }); } @@ -186,11 +192,11 @@ 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; - this._mainWindow.Database.TagsAdded += this.TagDatabase_OnTagsAdded; + if (!this._mainWindow.TagDatabaseService.IsDatabaseOpen) return; + this._mainWindow.TagDatabaseService.TagUpdated += this.TagDatabase_OnTagUpdated; + this._mainWindow.TagDatabaseService.TagAdded += this.TagDatabase_OnTagAdded; + this._mainWindow.TagDatabaseService.TagDeleted += this.TagDatabase_OnTagDeleted; + this._mainWindow.TagDatabaseService.TagsAdded += this.TagDatabase_OnTagsAdded; } private async void TagDatabase_OnTagAdded(object? sender, Tag newTag) diff --git a/UI/ViewModels/ImportDialogViewModel.cs b/UI/ViewModels/ImportDialogViewModel.cs index 27e5599..07fd815 100644 --- a/UI/ViewModels/ImportDialogViewModel.cs +++ b/UI/ViewModels/ImportDialogViewModel.cs @@ -1,6 +1,10 @@ using System; using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.ViewModels; @@ -32,12 +36,15 @@ public partial class ImportDialogViewModel(MainWindowViewModel mainWindow) : Vie private MainWindowViewModel MainWindow { get; } = mainWindow; - public async Task InitiateImport() + [RelayCommand] + private async Task InitiateImport() { try { + if (string.IsNullOrEmpty(this.DatabaseFilePath) || string.IsNullOrEmpty(this.TemplateFilePath)) return; + this.ImportStatus = Resources.ImportStatusInProgress; - await this.MainWindow.CreateNewDatabase(this.DatabaseFilePath, this.TemplateFilePath); + await this.MainWindow.TagDatabaseService.CreateNewDatabase(this.DatabaseFilePath, this.TemplateFilePath); this.RequestClose?.Invoke(); } catch (Exception ex) @@ -47,4 +54,70 @@ public partial class ImportDialogViewModel(MainWindowViewModel mainWindow) : Vie error.ShowDialog(); } } + + [RelayCommand] + private async Task ShowDatabaseBrowseDialog() + { + try + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) return; + + var userWantsToSave = await this.MainWindow.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; + + var storageProvider = desktop.MainWindow?.StorageProvider; + if (storageProvider is null) return; + + var file = await storageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Resources.DialogTitleSaveDatabaseAs, + FileTypeChoices = [Common.TagDatabaseFileType] + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + this.DatabaseFilePath = path; + } + catch (Exception ex) + { + this.TemplateFilePath = string.Empty; + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } + + [RelayCommand] + private async Task ShowTemplateBrowseDialog() + { + try + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) return; + + var userWantsToSave = await this.MainWindow.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; + + var storageProvider = desktop.MainWindow?.StorageProvider; + if (storageProvider is null) return; + + var files = await storageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + AllowMultiple = false, + Title = Resources.DialogTitleChooseImportTemplate, + FileTypeFilter = [Common.MusicBeeTagHierarchy] + }); + if (files.Count == 0) return; + var path = files[0].TryGetLocalPath(); + if (path == null) return; + this.TemplateFilePath = path; + } + catch (Exception ex) + { + this.TemplateFilePath = string.Empty; + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } } \ No newline at end of file diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index 46bea4d..1b36077 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -1,30 +1,27 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using TagHierarchyManager.Common; -using TagHierarchyManager.Exporters; -using TagHierarchyManager.Importers; using TagHierarchyManager.Models; using TagHierarchyManager.UI.Assets; using TagHierarchyManager.UI.Views; namespace TagHierarchyManager.UI.ViewModels; +// this has become a "god" view model and becoming unwieldy to manage. public partial class MainWindowViewModel : ViewModelBase { - internal TagDatabase? Database; - [ObservableProperty] private HierarchyTreeViewModel? _hierarchyTreeViewModel; [ObservableProperty] private bool _isDbEnabled; + private bool _isSwitching; [ObservableProperty] private SearchViewModel? _searchViewModel; @@ -34,13 +31,23 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _statusBlockText = Resources.StatusBlockReady; + [ObservableProperty] private TagDatabaseService _tagDatabaseService; + [ObservableProperty] private bool _unsavedChanges; - public int TotalTags => this.Database?.Tags.Count ?? 0; + public MainWindowViewModel(TagDatabaseService tagDatabaseService) + { + this.TagDatabaseService = tagDatabaseService; + this.TagDatabaseService.InitialisationComplete += this.TagDatabaseService_OnInitalisationComplete; + this.TagDatabaseService.TagAdded += this.TagDatabaseService_TagAdded; + this.TagDatabaseService.TagDeleted += this.TagDatabaseService_TagDeleted; + } + + public int TotalTags => this.TagDatabaseService.TagCount; public string WindowTitle => - this.Database is not null - ? string.Format(Resources.TitleWithDatabase, this.Database?.Name) + this.TagDatabaseService.IsDatabaseOpen + ? string.Format(Resources.TitleWithDatabase, this.TagDatabaseService.DatabaseName) : Resources.Title; public TagItemViewModel? SelectedTag @@ -67,85 +74,29 @@ public partial class MainWindowViewModel : ViewModelBase } } - public async Task CreateNewDatabase(string filePath, string? templateFilePath = null) - { - if (this.Database != null) - this.UninitialiseDatabase(); - - Dictionary? tagsToImport = null; - TagDatabase db = new(); - db.InitialisationComplete += this.TagDatabase_OnInitalisationComplete; - - try - { - if (string.IsNullOrWhiteSpace(filePath)) return; - - if (templateFilePath is not null && File.Exists(templateFilePath)) - { - var importer = this.PickImporterFromFileExt(templateFilePath); - tagsToImport = await importer.ImportFromFileAsync(templateFilePath); - } - - // 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, tagsToImport)); - } - catch (Exception ex) - { - db.InitialisationComplete -= this.TagDatabase_OnInitalisationComplete; - if (templateFilePath is null) this.ShowErrorDialog(ex.Message); - else throw; - } - } - - public async Task ExportAsync(string path) + public async Task NewTag() { - if (this.Database is null || string.IsNullOrWhiteSpace(path)) return; - this.IsDbEnabled = false; - 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); - } + var userWantsToSave = await this.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; - public async Task LoadDatabase(string 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.ShowErrorDialog(ex.Message); - } - } - - public void NewTag() - { this.SelectedTag = new TagItemViewModel( new Tag { Name = string.Empty, IsTopLevel = true, - TagBindings = this.Database!.DefaultTagBindings + TagBindings = this.TagDatabaseService.DefaultTagBindings } ); this.UnsavedChanges = true; } + [RelayCommand] public async Task SaveSelectedTagAsync() { - if (this.SelectedTag is null || this.Database is null) return; + if (this.SelectedTag is null || !this.TagDatabaseService.IsDatabaseOpen || !this.IsDbEnabled) return; this.SelectedTag.CommitEdit(); - await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); + await this.TagDatabaseService.WriteTagsToDatabase([this.SelectedTag.Tag]); this.SelectedTag.RefreshParentsString(); this.StatusBlockText = string.Format(Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); this.UnsavedChanges = false; @@ -200,42 +151,57 @@ public partial class MainWindowViewModel : ViewModelBase return result; } + public async Task ShowUnsavedChangesDialog() + { + if (!this.UnsavedChanges) return true; + var result = await this.ShowNullableBoolDialog(new UnsavedChangesDialog()); + return result; + } + public void StartSearch(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { this.SearchViewModel?.Search(searchQuery, mode, searchAliases); } - public async Task StartTagDeletion() + [RelayCommand] + private async Task CancelTagEditAsync() { - var result = await this.ShowNullableBoolDialog(new DeleteTagDialog()); - if (result == null) - return; + if (this.UnsavedChanges) + { + var userWantsToOverwrite = await this.ShowNullableBoolDialog(new UnsavedCancelDialog()); + if (userWantsToOverwrite is not null && (bool)!userWantsToOverwrite) return; + } - if (result == true) await this.DeleteSelectedTagAsync(); + this.SelectedTag?.BeginEdit(); + this.UnsavedChanges = false; } - private static IExporter PickExporterFromFileExt(string path) + private void CloseDatabase() { - var 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."); + this.SelectedTag = null; + this.IsDbEnabled = false; + this.HierarchyTreeViewModel = null; + this.SearchViewModel = null; + this.OnPropertyChanged(nameof(this.TotalTags)); + this.OnPropertyChanged(nameof(this.WindowTitle)); + this.TagDatabaseService.CloseDatabase(); } private async Task DeleteSelectedTagAsync() { - if (this.SelectedTag is null || this.Database is null) return; + var oldTag = this.SelectedTag; + if (this.SelectedTag is null || this.TagDatabaseService.IsDatabaseOpen) return; try { - await this.Database.DeleteTag(this.SelectedTag.Tag.Id); + await this.TagDatabaseService.DeleteTag(this.SelectedTag.Tag.Id); this._selectedTag = null; this.HierarchyTreeViewModel?.SelectedTag = null; this.OnPropertyChanged(nameof(this.SelectedTag)); } catch (Exception ex) { + this._selectedTag = oldTag; + // TODO invoke deleting/delete error so hierarchy tree can recover this.ShowErrorDialog(ex.Message); } } @@ -247,18 +213,23 @@ public partial class MainWindowViewModel : ViewModelBase { var result = await this.ShowNullableBoolDialog(new UnsavedChangesDialog()); - if (result == null) + switch (result) { - this._selectedTag = oldTag; - this.HierarchyTreeViewModel?.SelectedTag = oldTag; - this.OnPropertyChanged(nameof(this.SelectedTag)); - return; + case null: + this._selectedTag = oldTag; + this.HierarchyTreeViewModel?.SelectedTag = oldTag; + this.OnPropertyChanged(nameof(this.SelectedTag)); + return; + case false: + break; + case true: + await this.SaveSelectedTagAsync(); + break; } - if (result == true) - await this.SaveSelectedTagAsync(); - - + // do not set this to the generated public variable from Avalonia. + // OnPropertyChanged must be handled manually here to prevent tag changes firing + // twice. this._selectedTag = newTag; this.HierarchyTreeViewModel?.SelectedTag = newTag; this._selectedTag?.BeginEdit(); @@ -272,21 +243,74 @@ public partial class MainWindowViewModel : ViewModelBase } } + + [RelayCommand] + private async Task NewDatabaseAsync() + { + try + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) return; + + var userWantsToSave = await this.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; + + var storageProvider = desktop.MainWindow?.StorageProvider; + if (storageProvider is null) return; + + var file = await storageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Resources.DialogTitleSaveDatabaseAs, + FileTypeChoices = [Common.TagDatabaseFileType] + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + await this.TagDatabaseService.CreateNewDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } + private void OnUserEditedTag(object? sender, EventArgs e) { this.UnsavedChanges = true; } - private Importer PickImporterFromFileExt(string path) + [RelayCommand] + private async Task OpenDatabase() { - var fileExt = Path.GetExtension(path); + try + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) return; - // 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(); + var userWantsToSave = await this.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; - throw new NotSupportedException(string.Format(Resources.ErrorImportFileTypeNotSupported, fileExt)); + var storageProvider = desktop.MainWindow?.StorageProvider; + if (storageProvider is null) return; + + var files = await storageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + AllowMultiple = false, + Title = Resources.DialogTitleOpenDatabase, + FileTypeFilter = [Common.TagDatabaseFileType] + }); + if (files.Count == 0) return; + var path = files[0].TryGetLocalPath(); + if (path == null) return; + await this.TagDatabaseService.LoadDatabase(path); + } + catch (Exception ex) + { + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } } private void ShowErrorDialog(string message) @@ -295,9 +319,63 @@ public partial class MainWindowViewModel : ViewModelBase error.ShowDialog(); } - private void TagDatabase_OnInitalisationComplete(object? sender, EventArgs e) + [RelayCommand] + private async Task StartExportAsync() + { + if (!this.TagDatabaseService.IsDatabaseOpen || + Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || + desktop.MainWindow is null) return; + + var userWantsToSave = await this.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; + + var storageProvider = desktop.MainWindow?.StorageProvider; + if (storageProvider is null) return; + + try + { + var file = await storageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + Title = Resources.DialogTitleExportTagDatabase, + FileTypeChoices = [Common.MusicBeeTagHierarchy], + SuggestedFileName = this.TagDatabaseService.DatabaseName + }); + var path = file?.TryGetLocalPath(); + if (path == null) return; + this.IsDbEnabled = false; + this.StatusBlockText = Resources.StatusBlockExportInProgress; + await this.TagDatabaseService.ExportAsync(path); + this.IsDbEnabled = true; + this.StatusBlockText = string.Format(Resources.StatusBlockExportSuccessful, path); + } + catch (Exception ex) + { + this.IsDbEnabled = true; + var error = new ErrorDialogViewModel(ex.Message); + error.ShowDialog(); + } + } + + [RelayCommand] + private async Task StartTagDeletionAsync() { - if (sender is not TagDatabase db) return; + if (this.SelectedTag is null || this.SelectedTag.HasChildren) return; + var result = await this.ShowNullableBoolDialog(new DeleteTagDialog()); + switch (result) + { + case null: + case false: + return; + case true: + await this.DeleteSelectedTagAsync(); + break; + } + } + + private void TagDatabaseService_OnInitalisationComplete(object? sender, EventArgs e) + { + if (sender is not TagDatabaseService tdb) return; Dispatcher.UIThread.Post(async void () => { try @@ -305,59 +383,36 @@ public partial class MainWindowViewModel : ViewModelBase (this.HierarchyTreeViewModel as IDisposable)?.Dispose(); (this.SearchViewModel as IDisposable)?.Dispose(); - if (this.Database != null) - { - this.Database.TagAdded -= this.TagDatabase_TagAdded; - this.Database.TagDeleted -= this.TagDatabase_TagDeleted; - } - - this.Database = db; this.SelectedTag = null; 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.IsDbEnabled = true; - this.Database.InitialisationComplete -= this.TagDatabase_OnInitalisationComplete; this.UnsavedChanges = false; - this.StatusBlockText = string.Format(Resources.StatusBlockDbLoadSuccessful, this.Database.Name); + this.StatusBlockText = string.Format(Resources.StatusBlockDbLoadSuccessful, + this.TagDatabaseService.DatabaseName); } catch (Exception ex) { - this.UninitialiseDatabase(); + this.CloseDatabase(); this.ShowErrorDialog(ex.Message); } }); - Debug.WriteLine($"Database loaded on UI - name: {db.Name}, version: {db.Version}"); } - private void TagDatabase_TagAdded(object? sender, Tag _) + private void TagDatabaseService_TagAdded(object? sender, Tag _) { this.OnPropertyChanged(nameof(this.TotalTags)); this.SelectedTag!.SyncId(); } - private void TagDatabase_TagDeleted(object? sender, (int id, string name) _) + private void TagDatabaseService_TagDeleted(object? sender, (int id, string name) _) { this.OnPropertyChanged(nameof(this.TotalTags)); } - - private void UninitialiseDatabase() - { - if (this.Database == null) return; - this.SelectedTag = null; - this.IsDbEnabled = false; - this.Database.TagAdded -= this.TagDatabase_TagAdded; - this.Database.TagDeleted -= this.TagDatabase_TagDeleted; - this.Database = null; - 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 b8bfcbd..7a1c6bf 100644 --- a/UI/ViewModels/SearchViewModel.cs +++ b/UI/ViewModels/SearchViewModel.cs @@ -20,28 +20,23 @@ public partial class SearchViewModel : ViewModelBase, IDisposable public SearchViewModel(MainWindowViewModel mainWindow) { this._mainWindow = mainWindow; - this._getParentNamesById = parents => - parents.Select(id => this._mainWindow.Database?.Tags.FirstOrDefault(t => t.Id == id)) - .Where(tag => tag is not null) - .Select(tag => tag!.Name) - .ToList(); - mainWindow.Database?.TagDeleted += this.TagDatabase_OnTagDeleted; + this._getParentNamesById = mainWindow.TagDatabaseService.GetParentNamesByIds; + mainWindow.TagDatabaseService.TagDeleted += this.TagDatabase_OnTagDeleted; } public void Dispose() { - this._mainWindow.Database?.TagDeleted -= this.TagDatabase_OnTagDeleted; + this._mainWindow.TagDatabaseService.TagDeleted -= this.TagDatabase_OnTagDeleted; } public void Search(string searchQuery, TagDatabaseSearchMode mode, bool searchAliases) { - if (this._mainWindow.Database == null || string.IsNullOrWhiteSpace(searchQuery)) return; - - var results = searchAliases - ? this._mainWindow.Database.SearchWithAliases(searchQuery, mode) - : this._mainWindow.Database.Search(searchQuery, mode); + if (!this._mainWindow.TagDatabaseService.IsDatabaseOpen || string.IsNullOrWhiteSpace(searchQuery)) return; this.SearchResults.Clear(); + + var results = this._mainWindow.TagDatabaseService.SearchTags(searchQuery, mode, searchAliases); + if (results.Count == 0) { this._mainWindow.StatusBlockText = Resources.SearchNoResultsFound; diff --git a/UI/ViewModels/TagItemViewModel.cs b/UI/ViewModels/TagItemViewModel.cs index 90e92f0..0473ca0 100644 --- a/UI/ViewModels/TagItemViewModel.cs +++ b/UI/ViewModels/TagItemViewModel.cs @@ -148,13 +148,13 @@ public partial class TagItemViewModel(Tag tag, Func, List>? ge StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); - if (string.IsNullOrWhiteSpace(this.Name)) + if (string.IsNullOrWhiteSpace(this.EditingName)) throw new ArgumentException(Resources.ErrorBlankTagName); if (!this.EditingIsTopLevel && parentNames.Count == 0) throw new InvalidOperationException(Resources.ErrorOrphanTagAttempt); - if (parentNames.Contains(this.Name)) + if (parentNames.Contains(this.EditingName)) throw new InvalidOperationException(Resources.ErrorSelfParentAttempt); } } \ No newline at end of file diff --git a/UI/Views/BulkAddWindow.axaml.cs b/UI/Views/BulkAddWindow.axaml.cs index 7b93462..0ae4d85 100644 --- a/UI/Views/BulkAddWindow.axaml.cs +++ b/UI/Views/BulkAddWindow.axaml.cs @@ -8,6 +8,7 @@ namespace TagHierarchyManager.UI.Views; public partial class BulkAddWindow : Window { + // TODO keydown del = delete selected row/s public BulkAddWindow() { this.InitializeComponent(); diff --git a/UI/Views/Common.cs b/UI/Views/Common.cs index 0aa25e2..3587328 100644 --- a/UI/Views/Common.cs +++ b/UI/Views/Common.cs @@ -1,7 +1,7 @@ using Avalonia.Platform.Storage; using TagHierarchyManager.UI.Assets; -namespace TagHierarchyManager.UI.Views; +namespace TagHierarchyManager.UI; public static class Common { diff --git a/UI/Views/ImportDialog.axaml b/UI/Views/ImportDialog.axaml index 1fe9523..48a0e44 100644 --- a/UI/Views/ImportDialog.axaml +++ b/UI/Views/ImportDialog.axaml @@ -17,7 +17,8 @@ Foreground="{DynamicResource SemiColorTertiary}" Text="{Binding VisibleTemplateFilePath}" />