diff --git a/Core/Models/Tag/Tag.cs b/Core/Models/Tag/Tag.cs index de0b2d7..219e399 100644 --- a/Core/Models/Tag/Tag.cs +++ b/Core/Models/Tag/Tag.cs @@ -34,6 +34,7 @@ public partial class Tag public string Notes { get; set; } = string.Empty; // TODO ensure ParentIds is the authority on parent-child relationships in both the core + UI. + // TODO convert these to hashsets /// /// Gets or sets a list of the tag entry's parent IDs for interaction with the database. /// diff --git a/UI/ViewModels/HierarchyTreeViewModel.cs b/UI/ViewModels/HierarchyTreeViewModel.cs index e33e6f9..2c01f57 100644 --- a/UI/ViewModels/HierarchyTreeViewModel.cs +++ b/UI/ViewModels/HierarchyTreeViewModel.cs @@ -10,17 +10,25 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable { + private readonly Func, List> _getParentNamesById; private readonly MainWindowViewModel _mainWindow; - private readonly Dictionary _viewModelMap = new(); + + [ObservableProperty] private Dictionary> _childNodeMap = new(); [ObservableProperty] private TagItemViewModel? _selectedTag; - [ObservableProperty] private ObservableCollection _topLevelTags = []; + [ObservableProperty] private ObservableCollection _topLevelTagNodes = []; + + [ObservableProperty] private Dictionary> _viewModelMap = new(); 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.SubscribeToEvents(); } @@ -31,132 +39,225 @@ public partial class HierarchyTreeViewModel : ViewModelBase, IDisposable this._mainWindow.Database.TagUpdated -= this.TagDatabase_OnTagUpdated; this._mainWindow.Database.TagAdded -= this.TagDatabase_OnTagAdded; this._mainWindow.Database.TagDeleted -= this.TagDatabase_OnTagDeleted; - - this._viewModelMap.Clear(); } public async Task InitializeAsync() { - await this.SyncHierarchyAsync(); + if (this._mainWindow.Database is null) return; + // await this.SyncHierarchyAsync(); + this.ViewModelMap.Clear(); + await Task.Run(() => + { + var topLevelTags = this._mainWindow.Database?.Tags.Where(t => t.IsTopLevel).ToList(); + var buildingTopLevelNodes = new List(); + 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); + }); } - private TagItemViewModel GetOrCreateViewModel(Tag tag, int parentId) + private void AddAllChildrenAsync(TagItemViewModel tag, bool beingUpdated = false) { - var key = $"{parentId}_{tag.Id}"; - if (!this._viewModelMap.TryGetValue(key, out var viewModel)) + if (beingUpdated) { - viewModel = new TagItemViewModel(tag, id => - this._viewModelMap.Values.FirstOrDefault(v => v.Id == id)?.Name); - this._viewModelMap[key] = viewModel; - viewModel.UserEditedTag += this.OnUserEditedTag; + tag.Children.Clear(); + if (this.ChildNodeMap.TryGetValue(tag.Id, out var existingParents)) + existingParents.Clear(); } - return viewModel; + var childTags = + this._mainWindow.Database?.Tags.Where(t => t.ParentIds.Contains(tag.Id)).OrderBy(t => t.Name).ToList(); + + if (childTags is null) return; + foreach (var childNode in childTags.Select(child => + new TagItemViewModel(child, this._getParentNamesById))) + { + if (!this.ChildNodeMap.ContainsKey(childNode.Id)) + this.ChildNodeMap.Add(childNode.Tag.Id, []); + this.ChildNodeMap[childNode.Tag.Id].Add(tag.Id); + + this.AddTagNodeToViewModelMap(childNode, beingUpdated); + + tag.Children.Add(childNode); + this.AddAllChildrenAsync(childNode); + } } - - partial void OnSelectedTagChanged(TagItemViewModel? value) + + private async Task AddChildNode(Tag tag, int parentId) { - this._mainWindow.SelectedTag = value; + if (!this.ViewModelMap.TryGetValue(parentId, out var parentViewModels)) return; + + foreach (var parent in parentViewModels) + await Task.Run(() => + { + var tagNode = new TagItemViewModel(tag, this._getParentNamesById); + this.AddAllChildrenAsync(tagNode); + + var index = 0; + while (index < parent.Children.Count && string.Compare(parent.Children[index].Name, tagNode.Name, + StringComparison.CurrentCultureIgnoreCase) < 0) index++; + + parent.Children.Insert(index, tagNode); + this.AddTagNodeToViewModelMap(tagNode); + }); + + if (!this.ChildNodeMap.TryGetValue(tag.Id, out var set)) + this.ChildNodeMap.Add(tag.Id, [parentId]); + else + set.Add(parentId); } - private async Task OnTreeUpdate() + private void AddTagNodeToViewModelMap(TagItemViewModel tagNode, bool beingUpdated = false) { + if (!this.ViewModelMap.TryGetValue(tagNode.Id, out var tagNodeSet)) + { + this.ViewModelMap.Add(tagNode.Id, [tagNode]); + } + else + { + if (beingUpdated) tagNodeSet.Clear(); + tagNodeSet.Add(tagNode); + } + } + + private async Task AddTopLevelNode(Tag tag) + { + if (this.TopLevelTagNodes.Any(t => t.Id == tag.Id)) return; await Task.Run(() => { - foreach (var viewModel in this._viewModelMap.Values) viewModel.RefreshParentsString(); + 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); + this.AddTagNodeToViewModelMap(newTopLevelTag); }); - await this.SyncHierarchyAsync(); } - - private void OnUserEditedTag(object? sender, EventArgs e) => this._mainWindow.UnsavedChanges = true; - private void SubscribeToEvents() + private async Task DeleteChildNode(int parentId, int idToDelete) { - 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; + if (!this.ViewModelMap.TryGetValue(parentId, out var parentViewModels)) return; + + foreach (var parentTag in parentViewModels) + await Task.Run(() => + { + var foundChild = + parentTag.Children.FirstOrDefault(t => t.Id == idToDelete)!; + parentTag.Children.Remove(foundChild); + }); + + if (!this.ChildNodeMap.TryGetValue(idToDelete, out var parentSet)) return; + parentSet.Remove(parentId); } - private void SyncCollection(ObservableCollection collection, List newItems) + private async Task DeleteTopLevelNode(int idToDelete) { - var updatedKeys = newItems.Select(v => v.Id).ToHashSet(); - for (var i = collection.Count - 1; i >= 0; i--) - if (!updatedKeys.Contains(collection[i].Id)) - collection.RemoveAt(i); - - var currentKeys = collection.Select(v => v.Id).ToHashSet(); - foreach (var newItem in newItems) - if (!currentKeys.Contains(newItem.Id)) - { - // Find the correct index to maintain alphabetical order - var index = 0; - while (index < collection.Count && string.Compare(collection[index].Name, newItem.Name, - StringComparison.CurrentCultureIgnoreCase) < 0) index++; - collection.Insert(index, newItem); - } + var topLevelNode = this.TopLevelTagNodes.FirstOrDefault(t => t.Id == idToDelete); + if (topLevelNode is null) return; + await Task.Run(() => { this.TopLevelTagNodes.Remove(topLevelNode); }); } - private async Task SyncHierarchyAsync() + partial void OnSelectedTagChanged(TagItemViewModel? value) { - if (this._mainWindow.Database is null) return; + this._mainWindow.SelectedTag = value; + } - var activeKeys = new HashSet(); + 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; + } - var result = await Task.Run(() => + private async void TagDatabase_OnTagAdded(object? sender, Tag newTag) + { + try { - var children = this._mainWindow.Database.Tags - .SelectMany(t => t.ParentIds.Select(pId => new { ParentId = pId, Child = t })) - .ToLookup(x => x.ParentId, x => x.Child); - var topLevelTags = this._mainWindow.Database.Tags.Where(t => t.IsTopLevel).OrderBy(t => t.Name) - .ToList(); + if (newTag.IsTopLevel) + await this.AddTopLevelNode(newTag); - return (topLevelTags, children); - }); + if (newTag.ParentIds.Count == 0) return; + foreach (var parentId in newTag.ParentIds) + await this.AddChildNode(newTag, parentId); + } + catch (Exception e) + { + var error = new ErrorDialogViewModel(e.Message); + error.ShowDialog(); + } + } - var topLevelViewModels = result.topLevelTags.Select(t => + private async void TagDatabase_OnTagDeleted(object? sender, (int id, string name) deletedTag) + { + try { - var vm = this.GetOrCreateViewModel(t, 0); - activeKeys.Add($"0_{t.Id}"); - this.SyncTagRecursive(vm, result.children, activeKeys); - return vm; - }).ToList(); - this.SyncCollection(this.TopLevelTags, topLevelViewModels); - - var keysToRemove = this._viewModelMap.Keys.Where(k => !activeKeys.Contains(k)).ToList(); - foreach (var key in keysToRemove) + await this.WipeTagNodes(deletedTag.id); + } + catch (Exception e) { - this._viewModelMap[key].UserEditedTag -= this.OnUserEditedTag; - this._viewModelMap.Remove(key); + var error = new ErrorDialogViewModel(e.Message); + error.ShowDialog(); } } - private void SyncTagRecursive(TagItemViewModel parentVm, ILookup childrenLookup, - HashSet activeKeys) + private async void TagDatabase_OnTagUpdated(object? sender, Tag updatedTag) { - var childTags = childrenLookup[parentVm.Id].OrderBy(t => t.Name).ToList(); - var childVms = new List(); - - foreach (var ct in childTags) + try { - var key = $"{parentVm.Id}_{ct.Id}"; - activeKeys.Add(key); + if (!this.ViewModelMap.TryGetValue(updatedTag.Id, out var tagViewModels)) return; - var childVm = this.GetOrCreateViewModel(ct, parentVm.Id); - childVms.Add(childVm); + HashSet oldParents = []; + HashSet newParents = new(updatedTag.ParentIds); - this.SyncTagRecursive(childVm, childrenLookup, activeKeys); - } + // grab old parents if they exist + if (this.ChildNodeMap.TryGetValue(updatedTag.Id, out var parentList)) + oldParents = [..parentList]; - parentVm.SyncChildren(childVms); - } + // add/remove parents as necessary + var removedParents = oldParents.Except(newParents); + var addedParents = newParents.Except(oldParents); + + foreach (var parentId in removedParents) + await this.DeleteChildNode(parentId, updatedTag.Id); + + foreach (var parentId in addedParents) + await this.AddChildNode(updatedTag, parentId); - private void TagDatabase_OnTagAdded(object? sender, Tag tag) => - _ = Task.Run(async () => await this.OnTreeUpdate()); + // refresh all tagViewModels + foreach (var tag in tagViewModels) + tag.RefreshSelf(); - private void TagDatabase_OnTagDeleted(object? sender, (int id, string name) tag) => - _ = Task.Run(async () => await this.OnTreeUpdate()); + // add/remove top level nodes as necessary + if (updatedTag.IsTopLevel) + await this.AddTopLevelNode(updatedTag); + else + await this.DeleteTopLevelNode(updatedTag.Id); - private void TagDatabase_OnTagUpdated(object? sender, Tag tag) => - _ = Task.Run(async () => await this.OnTreeUpdate()); + // clear child node map if tag has no parents + if (newParents.Count == 0) + this.ChildNodeMap.Remove(updatedTag.Id); + } + catch (Exception e) + { + var error = new ErrorDialogViewModel(e.Message); + error.ShowDialog(); + } + } + + private async Task WipeTagNodes(int idToDelete) + { + if (this.ChildNodeMap.TryGetValue(idToDelete, out var parentList)) + { + foreach (var parentId in parentList) await this.DeleteChildNode(parentId, idToDelete); + this.ChildNodeMap.Remove(idToDelete); + this.ViewModelMap.Remove(idToDelete); + } + + await this.DeleteTopLevelNode(idToDelete); + } } \ No newline at end of file diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index 8cb96ed..fad9f5e 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Threading.Tasks; @@ -35,8 +34,6 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _statusBlockText = Resources.StatusBlockReady; - [ObservableProperty] private ObservableCollection _topLevelTags = []; - [ObservableProperty] private bool _unsavedChanges; public int TotalTags => this.Database?.Tags.Count ?? 0; @@ -59,11 +56,13 @@ public partial class MainWindowViewModel : ViewModelBase } else { + this._selectedTag?.UserEditedTag -= this.OnUserEditedTag; this._selectedTag = value; this.HierarchyTreeViewModel?.SelectedTag = value; this._selectedTag?.BeginEdit(); this.OnPropertyChanged(); this.UnsavedChanges = false; + this._selectedTag?.UserEditedTag += this.OnUserEditedTag; } } } @@ -144,7 +143,7 @@ public partial class MainWindowViewModel : ViewModelBase public async Task SaveSelectedTagAsync() { if (this.SelectedTag is null || this.Database is null) return; - + this.SelectedTag.CommitEdit(); await this.Database.WriteTagToDatabase(this.SelectedTag.Tag); this.SelectedTag.RefreshParentsString(); @@ -246,9 +245,11 @@ public partial class MainWindowViewModel : ViewModelBase if (result == true) await this.SaveSelectedTagAsync(); + this._selectedTag = newTag; this.HierarchyTreeViewModel?.SelectedTag = newTag; this._selectedTag?.BeginEdit(); + this._selectedTag?.UserEditedTag += this.OnUserEditedTag; this.OnPropertyChanged(nameof(this.SelectedTag)); this.UnsavedChanges = false; } @@ -258,6 +259,11 @@ public partial class MainWindowViewModel : ViewModelBase } } + private void OnUserEditedTag(object? sender, EventArgs e) + { + this.UnsavedChanges = true; + } + private Importer PickImporterFromFileExt(string path) { var fileExt = Path.GetExtension(path); @@ -270,6 +276,12 @@ public partial class MainWindowViewModel : ViewModelBase throw new NotSupportedException(string.Format(Resources.ErrorImportFileTypeNotSupported, fileExt)); } + private void ShowErrorDialog(string message) + { + var error = new ErrorDialogViewModel(message); + error.ShowDialog(); + } + private void TagDatabase_OnInitalisationComplete(object? sender, EventArgs e) { if (sender is not TagDatabase db) return; @@ -288,7 +300,7 @@ public partial class MainWindowViewModel : ViewModelBase 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; @@ -296,6 +308,7 @@ public partial class MainWindowViewModel : ViewModelBase 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; @@ -309,12 +322,6 @@ 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 _) { @@ -330,10 +337,11 @@ public partial class MainWindowViewModel : ViewModelBase 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.IsDbEnabled = false; this.HierarchyTreeViewModel = null; this.SearchViewModel = null; this.OnPropertyChanged(nameof(this.TotalTags)); diff --git a/UI/ViewModels/SearchViewModel.cs b/UI/ViewModels/SearchViewModel.cs index e884cda..b8bfcbd 100644 --- a/UI/ViewModels/SearchViewModel.cs +++ b/UI/ViewModels/SearchViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,6 +10,7 @@ namespace TagHierarchyManager.UI.ViewModels; public partial class SearchViewModel : ViewModelBase, IDisposable { + private readonly Func, List> _getParentNamesById; private readonly MainWindowViewModel _mainWindow; [ObservableProperty] private ObservableCollection _searchResults = []; @@ -18,10 +20,18 @@ 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; } - 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) { @@ -39,7 +49,7 @@ public partial class SearchViewModel : ViewModelBase, IDisposable } results.Select(tag => - new TagItemViewModel(tag, id => this._mainWindow.Database.Tags.FirstOrDefault(t => t.Id == id)?.Name)) + new TagItemViewModel(tag, this._getParentNamesById)) .OrderBy(tag => tag.Name) .ToList() .ForEach(this.SearchResults.Add); diff --git a/UI/ViewModels/TagItemViewModel.cs b/UI/ViewModels/TagItemViewModel.cs index 1a4cfa7..818c603 100644 --- a/UI/ViewModels/TagItemViewModel.cs +++ b/UI/ViewModels/TagItemViewModel.cs @@ -9,7 +9,8 @@ using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.ViewModels; -public partial class TagItemViewModel(Tag tag, Func? getNameById = null) : ViewModelBase +public partial class TagItemViewModel(Tag tag, Func, List>? getParentNamesByIds = null) + : ViewModelBase { [ObservableProperty] private string _editingAliases = string.Empty; @@ -32,8 +33,7 @@ public partial class TagItemViewModel(Tag tag, Func? getNameById = ? string.Join("; ", this.Tag.Aliases) : string.Empty; - - public ObservableCollection Children { get; } = []; + public bool HasChildren => this.Children.Count > 0; public int Id => this.Tag.Id; @@ -42,30 +42,30 @@ public partial class TagItemViewModel(Tag tag, Func? getNameById = public string Notes => this.Tag.Notes; public bool OnDatabase => this.Id != 0; - - public bool HasChildren => this.Children.Count > 0; public string TagBindings => this.Tag.TagBindings.Count > 0 ? string.Join("; ", this.Tag.TagBindings) : string.Empty; + public ObservableCollection Children { get; set; } = []; + internal Tag Tag { get; } = tag; private bool IsTopLevel => this.Tag.IsTopLevel; - private string Parents => getNameById != null && this.Tag.ParentIds is { Count: > 0 } - ? string.Join("; ", this.Tag.ParentIds.Select(getNameById).Where(n => n != null)) + private string Parents => getParentNamesByIds is not null && this.Tag.ParentIds is { Count: > 0 } + ? string.Join("; ", getParentNamesByIds(this.Tag.ParentIds)) : string.Empty; public void BeginEdit() { this._isInitialising = true; this.EditingName = this.Tag.Name; - + if (this.OnDatabase || string.IsNullOrEmpty(this.EditingParents)) this.EditingParents = this.Parents; - + this.EditingIsTopLevel = this.IsTopLevel; this.EditingTagBindings = this.TagBindings; this.EditingAliases = this.Aliases; @@ -113,29 +113,19 @@ public partial class TagItemViewModel(Tag tag, Func? getNameById = this.EditingParents = newParents; this.OnPropertyChanged(nameof(this.EditingParents)); } + this._isInitialising = false; } - - public void SyncChildren(List children) + public void RefreshSelf() { - var newChildren = children.Select(c => c.Id).ToHashSet(); - - for (var i = this.Children.Count - 1; i >= 0; i--) - if (!newChildren.Contains(this.Children[i].Id)) - this.Children.RemoveAt(i); - - var currentIds = this.Children.Select(c => c.Id).ToHashSet(); - foreach (var child in children) - if (!currentIds.Contains(child.Id)) - { - // Find the correct index to maintain alphabetical order - var index = 0; - while (index < this.Children.Count && string.Compare(this.Children[index].Name, child.Name, - StringComparison.CurrentCultureIgnoreCase) < 0) index++; - - this.Children.Insert(index, child); - } + this.OnPropertyChanged(nameof(this.Name)); + this.OnPropertyChanged(nameof(this.Parents)); + this.OnPropertyChanged(nameof(this.Aliases)); + this.OnPropertyChanged(nameof(this.TagBindings)); + this.OnPropertyChanged(nameof(this.Notes)); + this.OnPropertyChanged(nameof(this.IsTopLevel)); + this.OnPropertyChanged(nameof(this.HasChildren)); } public void SyncId() diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml index 5e4341f..c127e06 100644 --- a/UI/Views/MainWindow.axaml +++ b/UI/Views/MainWindow.axaml @@ -37,22 +37,22 @@ + RowDefinitions="*" + Margin="10 0" + IsEnabled="{Binding IsDbEnabled}"> - - - + + + - + @@ -72,7 +72,7 @@ diff --git a/UI/Views/MainWindow.axaml.cs b/UI/Views/MainWindow.axaml.cs index ec31eb1..628feb6 100644 --- a/UI/Views/MainWindow.axaml.cs +++ b/UI/Views/MainWindow.axaml.cs @@ -42,7 +42,7 @@ public partial class MainWindow : Window error.ShowDialog(); } } - + public async void ButtonSave_Click(object? sender, RoutedEventArgs e) { try @@ -72,7 +72,6 @@ public partial class MainWindow : Window var error = new ErrorDialogViewModel(ex.Message); error.ShowDialog(); } - } public void MenuItemDatabaseSettings_Click(object? sender, RoutedEventArgs e)