diff --git a/Core/Models/Tag/Tag.cs b/Core/Models/Tag/Tag.cs index bf62d52..de0b2d7 100644 --- a/Core/Models/Tag/Tag.cs +++ b/Core/Models/Tag/Tag.cs @@ -33,6 +33,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. /// /// Gets or sets a list of the tag entry's parent IDs for interaction with the database. /// diff --git a/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs index 7e4b569..2a3ce26 100644 --- a/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs +++ b/Core/Models/TagDatabase/TagDatabase.WriteMethods.cs @@ -43,12 +43,12 @@ public partial class TagDatabase if (index != -1) { this.Tags[index] = tag; - TagUpdated.Invoke(this, tag); + TagUpdated?.Invoke(this, tag); } else { this.Tags.Add(tag); - TagAdded.Invoke(this, tag); + TagAdded?.Invoke(this, tag); } } catch (SqliteException) diff --git a/UI/Assets/Resources.resx b/UI/Assets/Resources.resx index 0500e67..a08d0a0 100644 --- a/UI/Assets/Resources.resx +++ b/UI/Assets/Resources.resx @@ -20,6 +20,9 @@ Tag Hierarchy Manager + + {0} - Tag Hierarchy Manager + (c) Flaky 2025, licensed under MIT diff --git a/UI/TagHierarchyManager.UI.csproj b/UI/TagHierarchyManager.UI.csproj index b3ab760..499dcf9 100644 --- a/UI/TagHierarchyManager.UI.csproj +++ b/UI/TagHierarchyManager.UI.csproj @@ -10,6 +10,16 @@ + + Resources.Designer.cs + PublicResXFileCodeGenerator + + + + True + True + Resources.resx + @@ -25,20 +35,8 @@ - - - - PublicResXFileCodeGenerator - Resources.Designer.cs - - - + - - True - True - Resources.resx - AboutWindow.axaml Code @@ -56,7 +54,7 @@ Code - + diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index dbe8cea..80d8f6e 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -1,19 +1,23 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using TagHierarchyManager.Models; +using TagHierarchyManager.UI.Assets; namespace TagHierarchyManager.UI.ViewModels; -// TODO TagItemViewModel - public partial class MainWindowViewModel : ViewModelBase { private TagDatabase _database; + + + private readonly Dictionary _viewModelMap = new(); + [ObservableProperty] private TagItemViewModel? _selectedTag; @@ -22,44 +26,157 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool _isDbLoaded; - + + public int TotalTags => this._database?.Tags.Count ?? 0; + + public string WindowTitle => IsDbLoaded + ? string.Format(Resources.TitleWithDatabase, this._database.Name) + : Resources.Title; // TODO searchViewModel // TODO hierarchyViewModel public MainWindowViewModel() { } + + partial void OnSelectedTagChanged(TagItemViewModel? value) + { + value?.BeginEdit(); + } public async Task LoadDatabase(string filePath) { TagDatabase db = new(); - db.InitialisationComplete += (s, e) => + db.InitialisationComplete += OnDatabaseLoaded; + this.IsDbLoaded = false; + this.TopLevelTags.Clear(); + this._viewModelMap.Clear(); + await db.LoadAsync(filePath); + } + + public async Task SaveTag() + { + if (SelectedTag is null || _database is null) return; + var oldName = SelectedTag.Name; + + SelectedTag.CommitEdit(); + await _database.WriteTagToDatabase(SelectedTag.Tag); + } + + private void OnDatabaseLoaded(object sender, EventArgs e) + { + if (sender is not TagDatabase db) return; + Avalonia.Threading.Dispatcher.UIThread.Post(() => { - Avalonia.Threading.Dispatcher.UIThread.Post(() => + this._database = db; + this.IsDbLoaded = true; + this.SyncHierarchyAsync(); + this.OnPropertyChanged(nameof(TotalTags)); + this.OnPropertyChanged(nameof(WindowTitle)); + this._database.InitialisationComplete -= OnDatabaseLoaded; + this._database.TagUpdated += OnTagUpdated; + + }); + Debug.WriteLine($"Database loaded on UI - name: {db.Name}, version: {db.Version}"); + } + + private void OnTagUpdated(object sender, Tag updatedTag) + { + _ = Task.Run(async () => { + foreach (var viewModel in _viewModelMap.Values) { - this._database = db; - this.IsDbLoaded = true; - this.TopLevelTags.Clear(); - var tagMap = db.Tags.ToDictionary(t => t.Id, t => new TagItemViewModel(t)); - - foreach (var tag in db.Tags.OrderBy(t => t.Name)) - { - var viewModel = tagMap[tag.Id]; - foreach (var parentId in tag.ParentIds) - { - if (tagMap.TryGetValue(parentId, out var parentViewModel)) - { - parentViewModel.Children.Add(viewModel); - } - } - } - foreach (var tag in db.Tags.Where(t => t.IsTopLevel)) + viewModel.RefreshParentsString(); + } + this.SyncHierarchyAsync(); + }); + } + + public async Task SyncHierarchyAsync() + { + if (this._database is null) return; + + var activeKeys = new HashSet(); + + var result = await Task.Run(() => + { + var activeKeys = new HashSet(); + var children = _database.Tags + .SelectMany(t => t.ParentIds.Select(pId => new { ParentId = pId, Child = t })) + .ToLookup(x => x.ParentId, x => x.Child); + var topLevelTags = _database.Tags.Where(t => t.IsTopLevel).OrderBy(t => t.Name).ToList(); + + return (activeKeys, topLevelTags, children); + }); + + var topLevelViewModels = result.topLevelTags.Select(t => { + var vm = GetOrCreateViewModel(t, 0); + activeKeys.Add($"0_{t.Id}"); + SyncTagRecursive(vm, result.children, activeKeys); + return vm; + }).ToList(); + SyncCollection(TopLevelTags, topLevelViewModels); + + var keysToRemove = _viewModelMap.Keys.Where(k => !activeKeys.Contains(k)).ToList(); + foreach (var key in keysToRemove) + { + _viewModelMap.Remove(key); + } + } + + private void SyncTagRecursive(TagItemViewModel parentVm, ILookup childrenLookup, HashSet activeKeys) + { + var childTags = childrenLookup[parentVm.Id].OrderBy(t => t.Name).ToList(); + var childVms = new List(); + + foreach (var ct in childTags) + { + var key = $"{parentVm.Id}_{ct.Id}"; + activeKeys.Add(key); + + var childVm = GetOrCreateViewModel(ct, parentVm.Id); + childVms.Add(childVm); + + // Recurse: build this child's children + SyncTagRecursive(childVm, childrenLookup, activeKeys); + } + + parentVm.SyncChildren(childVms); + } + + private TagItemViewModel GetOrCreateViewModel(Tag tag, int parentId) + { + string key = $"{parentId}_{tag.Id}"; + if (!this._viewModelMap.TryGetValue(key, out var viewModel)) + { + viewModel = new TagItemViewModel(tag, id => + this._viewModelMap.Values.FirstOrDefault(v => v.Id == id)?.Name); + this._viewModelMap[key] = viewModel; + } + return viewModel; + } + + private void SyncCollection(ObservableCollection collection, List newItems) + { + var updatedKeys = newItems.Select(v => v.Id).ToHashSet(); + for (int 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 + int index = 0; + while (index < collection.Count && string.Compare(collection[index].Name, newItem.Name, StringComparison.CurrentCultureIgnoreCase) < 0) { - this.TopLevelTags.Add(tagMap[tag.Id]); + index++; } - }); - Debug.WriteLine($"Database loaded on UI - name: {db.Name}, version: {db.Version}"); - }; - await db.LoadAsync(filePath); + collection.Insert(index, newItem); + } + } } } \ No newline at end of file diff --git a/UI/ViewModels/TagItemViewModel.cs b/UI/ViewModels/TagItemViewModel.cs index b9211d9..cdf9088 100644 --- a/UI/ViewModels/TagItemViewModel.cs +++ b/UI/ViewModels/TagItemViewModel.cs @@ -1,36 +1,181 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using TagHierarchyManager.Models; namespace TagHierarchyManager.UI.ViewModels; -public class TagItemViewModel(Tag tag) : ViewModelBase +public class TagItemViewModel(Tag tag, Func getNameById) : ViewModelBase { - private Tag Tag { get; } = tag; + internal Tag Tag { get; } = tag; public int Id => Tag.Id; + + public string Name => Tag.Name; + + private string _editingName = tag.Name; + public string EditingName + { + get => _editingName; + set + { + if (_editingName == value) return; + _editingName = value; + OnPropertyChanged(); + } + } + + private string Parents => Tag.ParentIds.Count > 0 + ? string.Join("; ", Tag.ParentIds.Select(getNameById).Where(n => n != null)) + : string.Empty; + + private string _editingParents; + public string EditingParents + { + get => _editingParents; + set + { + if (_editingParents == value) return; + _editingParents = value; + OnPropertyChanged(); + } + } + + public string TagBindings => Tag.TagBindings.Count > 0 + ? string.Join("; ", Tag.TagBindings) + : string.Empty; + private string _editingTagBindings; - public string Name + public string EditingTagBindings { - get => Tag.Name; + get => _editingTagBindings; set { - if (Tag.Name == value) return; - Tag.Name = value; + if (_editingTagBindings == value) return; + _editingTagBindings = value; OnPropertyChanged(); } } - // parents are saved in the UI using a semi-colon separated string. - public string Parents + public string Aliases => Tag.Aliases.Count > 0 + ? string.Join("; ", Tag.Aliases) + : string.Empty; + private string _editingAliases; + + public string EditingAliases + { + get => _editingAliases; + set + { + if (_editingAliases == value) return; + _editingAliases = value; + OnPropertyChanged(); + } + } + + public string Notes => Tag.Notes; + private string _editingNotes; + + public string EditingNotes { - get => Tag.Parents.Count > 0 ? string.Join("; ", Tag.Parents) : string.Empty; + get => _editingNotes; set { - if (!Tag.Parents.SequenceEqual(value.Split(";"))) Tag.Parents = value.Split(";").ToList(); + if (_editingNotes == value) return; + _editingNotes = value; OnPropertyChanged(); } } + private bool IsTopLevel => Tag.IsTopLevel; + private bool _editingIsTopLevel; + public bool EditingIsTopLevel + { + get => _editingIsTopLevel; + set + { + if (_editingIsTopLevel == value) return; + _editingIsTopLevel = value; + OnPropertyChanged(); + } + } + + public ObservableCollection Children { get; } = []; + + public void BeginEdit() + { + EditingName = Tag.Name; + this._editingParents = Parents; + this._editingIsTopLevel = IsTopLevel; + this._editingTagBindings = TagBindings; + this._editingAliases = Aliases; + this._editingNotes = Notes; + } + + public void CommitEdit() + { + Tag.Name = EditingName; + Tag.Parents = !string.IsNullOrWhiteSpace(EditingParents) + ? EditingParents.Split(';', StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries) + .ToList() + : []; + Tag.TagBindings = !string.IsNullOrWhiteSpace(EditingTagBindings) + ? EditingTagBindings.Split(';', StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries) + .ToList() + : []; + Tag.Aliases = !string.IsNullOrWhiteSpace(EditingAliases) + ? EditingAliases.Split(';', StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries) + .ToList() + : []; + Tag.Notes = EditingNotes; + Tag.IsTopLevel = EditingIsTopLevel; + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(Parents)); + OnPropertyChanged(nameof(Aliases)); + OnPropertyChanged(nameof(TagBindings)); + OnPropertyChanged(nameof(Notes)); + OnPropertyChanged(nameof(IsTopLevel)); + RefreshParentsString(); + } + + public void RefreshParentsString() + { + OnPropertyChanged(nameof(Parents)); + _editingParents = Parents; + OnPropertyChanged(nameof(EditingParents)); + } + + public void SyncChildren(List children) + { + var newChildren = children.Select(c => c.Id).ToHashSet(); + + for (int i = Children.Count - 1; i >= 0; i--) + { + if (!newChildren.Contains(Children[i].Id)) + { + Children.RemoveAt(i); + } + } + + var currentIds = Children.Select(c => c.Id).ToHashSet(); + foreach (var child in children) + { + if (!currentIds.Contains(child.Id)) + { + // Find the correct index to maintain alphabetical order + int index = 0; + while (index < Children.Count && string.Compare(Children[index].Name, child.Name, StringComparison.CurrentCultureIgnoreCase) < 0) + { + index++; + } + Children.Insert(index, child); + } + } + + } } \ No newline at end of file diff --git a/UI/Views/DatabaseSettingsWindow.axaml b/UI/Views/DatabaseSettingsWindow.axaml index f400663..3efe6d1 100644 --- a/UI/Views/DatabaseSettingsWindow.axaml +++ b/UI/Views/DatabaseSettingsWindow.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="160" Width="500" Height="160" x:Class="TagHierarchyManager.UI.Views.DatabaseSettingsWindow" - Title="Settings - "> + Title="Settings - _DBNAME_"> diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml index 926becb..707831c 100644 --- a/UI/Views/MainWindow.axaml +++ b/UI/Views/MainWindow.axaml @@ -7,7 +7,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="TagHierarchyManager.UI.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Title="{x:Static assets:Resources.Title}"> + Title="{Binding WindowTitle}">