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/ErrorDialog.axaml.cs b/UI/Views/ErrorDialog.axaml.cs
new file mode 100644
index 0000000..1b928fd
--- /dev/null
+++ b/UI/Views/ErrorDialog.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace TagHierarchyManager.UI.Views;
+
+public partial class ErrorDialog : Window
+{
+ public ErrorDialog()
+ {
+ InitializeComponent();
+ }
+
+ public void OnOkClick(object? sender, RoutedEventArgs e) => this.Close();
+}
\ No newline at end of file
diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml
index aeed164..35e1b9c 100644
--- a/UI/Views/MainWindow.axaml
+++ b/UI/Views/MainWindow.axaml
@@ -29,10 +29,10 @@
-
+
-