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}">