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 @@ + + + + + +