diff --git a/Core/Models/Tag/Tag.cs b/Core/Models/Tag/Tag.cs index 13f20a1..415ee4d 100644 --- a/Core/Models/Tag/Tag.cs +++ b/Core/Models/Tag/Tag.cs @@ -32,9 +32,7 @@ public partial class Tag /// This is mainly for internal use by the user. /// 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. /// @@ -56,8 +54,8 @@ public partial class Tag /// Gets or sets the date the tag was created. /// Defaults to null to cater to migrated databases to prevent inaccurate information. /// - public DateTime? CreatedAt { get; set; } = null; - + public DateTime? CreatedAt { get; set; } + /// /// Gets or sets the date the tag was modified. /// diff --git a/Tests/TagDatabaseSaveTests.cs b/Tests/TagDatabaseSaveTests.cs index 2b317aa..f5f2a20 100644 --- a/Tests/TagDatabaseSaveTests.cs +++ b/Tests/TagDatabaseSaveTests.cs @@ -1,11 +1,8 @@ -using System.Xml.XPath; using NUnit.Framework; using TagHierarchyManager.Models; namespace TagHierarchyManager.Tests; -// todo catch events here. - /// /// Tests relating to saving of objects to a . /// diff --git a/UI/Assets/Resources.resx b/UI/Assets/Resources.resx index fb41806..90e395c 100644 --- a/UI/Assets/Resources.resx +++ b/UI/Assets/Resources.resx @@ -208,7 +208,7 @@ No file picked. - + Import... @@ -265,4 +265,28 @@ {0} (on row {1}) + + Date added + + + Date modified + + + Recently Added + + + Recently Edited + + + NOTE: Only newly added tags will show up on this table. If you have recently migrated, you will not see any tags here. + + + Recently added/edited tags + + + Recently Added/Edited Tags + + + Bulk Add Tags... + \ No newline at end of file diff --git a/UI/TagDatabaseService.cs b/UI/TagDatabaseService.cs index 895ca2d..6899078 100644 --- a/UI/TagDatabaseService.cs +++ b/UI/TagDatabaseService.cs @@ -29,12 +29,6 @@ public class TagDatabaseService : ObservableObject public int TagCount => this.Database?.Tags.Count ?? 0; public int TagRelationshipCount => this.Database?.GetTagRelationshipCount() ?? 0; - public sealed record TagWriteResult( - IReadOnlyList Added, - IReadOnlyList Updated, - IReadOnlyList<(int id, string name)> Deleted - ); - private TagDatabase? Database { get; set; } public void CloseDatabase() @@ -113,6 +107,20 @@ public class TagDatabaseService : ObservableObject .ToList(); } + public List GetRecentTags(bool recentlyAdded) + { + if (this.Database is null) return []; + return recentlyAdded + ? this.Database.Tags.Where(t => t.CreatedAt is not null).OrderByDescending(t => t.CreatedAt).Take(50) + .ToList() + : this.Database.Tags.OrderByDescending(t => t.UpdatedAt).Take(50).ToList(); + } + + public Tag? GetTagById(int id) + { + return this.Database?.Tags.FirstOrDefault(t => t.Id == id); + } + public async Task LoadDatabase(string filePath) { TagDatabase db = new(); @@ -120,7 +128,7 @@ public class TagDatabaseService : ObservableObject { if (this.Database != null) this.CloseDatabase(); - + db.InitialisationComplete += this.TagDatabase_OnInitialisationComplete; await Task.Run(() => db.LoadAsync(filePath)); } @@ -145,7 +153,7 @@ public class TagDatabaseService : ObservableObject StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); this.NotifyDatabasePropertiesChanged(); } - + public async Task WriteTagsToDatabase(List tags) { if (this.Database is null) return; @@ -193,7 +201,7 @@ public class TagDatabaseService : ObservableObject if (this.Database is null) return; this.Database.TagsWritten += this.TagDatabase_OnTagsWritten; } - + private void TagDatabase_OnInitialisationComplete(object? sender, EventArgs e) { this.Database = sender as TagDatabase; @@ -214,4 +222,10 @@ public class TagDatabaseService : ObservableObject if (this.Database is null) return; this.Database.TagsWritten -= this.TagDatabase_OnTagsWritten; } + + public sealed record TagWriteResult( + IReadOnlyList Added, + IReadOnlyList Updated, + IReadOnlyList<(int id, string name)> Deleted + ); } \ No newline at end of file diff --git a/UI/ViewModels/MainWindowViewModel.cs b/UI/ViewModels/MainWindowViewModel.cs index 7e4c523..b4b518f 100644 --- a/UI/ViewModels/MainWindowViewModel.cs +++ b/UI/ViewModels/MainWindowViewModel.cs @@ -22,7 +22,6 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private bool _isDbEnabled; - private bool _isSwitching; [ObservableProperty] private SearchViewModel? _searchViewModel; @@ -44,9 +43,7 @@ public partial class MainWindowViewModel : ViewModelBase this.TagDatabaseService.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(this.TagDatabaseService.TagCount)) - { this.OnPropertyChanged(nameof(this.TotalTags)); - } }; } @@ -81,94 +78,6 @@ public partial class MainWindowViewModel : ViewModelBase } } - [RelayCommand(AllowConcurrentExecutions = false)] - private async Task NewTag() - { - var userWantsToSave = await this.ShowUnsavedChangesDialog(); - if (userWantsToSave is null) return; - - this.SelectedTag = new TagItemViewModel( - new Tag - { - Name = string.Empty, - IsTopLevel = true, - TagBindings = this.TagDatabaseService.DefaultTagBindings - } - ); - this.UnsavedChanges = true; - } - - [RelayCommand] - private async Task SaveSelectedTagAsync() - { - try - { - if (this.SelectedTag is null || !this.TagDatabaseService.IsDatabaseOpen || !this.IsDbEnabled) return; - - this.SelectedTag.CommitEdit(); - await this.TagDatabaseService.WriteTagsToDatabase([this.SelectedTag.Tag]); - this.SelectedTag.RefreshParentsString(); - this.StatusBlockText = string.Format(Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); - this.UnsavedChanges = false; - } - catch (Exception ex) - { - this.ShowErrorDialog(ex.Message); - } - } - - [RelayCommand] - private void ShowBulkAddDialog() - { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - var dialog = new BulkAddWindow - { - DataContext = new BulkAddViewModel(this) - }; - - dialog.ShowDialog(desktop.MainWindow!); - } - - [RelayCommand] - private void ShowDatabaseSettings() - { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - var dialog = new DatabaseSettingsWindow - { - DataContext = new DatabaseSettingsViewModel(this) - }; - - dialog.ShowDialog(desktop.MainWindow!); - } - - [RelayCommand] - private void ShowImportDialog() - { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - var dialog = new ImportDialog - { - DataContext = new ImportDialogViewModel(this) - }; - - dialog.ShowDialog(desktop.MainWindow!); - } - - private async Task ShowNullableBoolDialog(Window dialog) - { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return null; - - var mainWindow = desktop.MainWindow; - var result = await dialog.ShowDialog(mainWindow!); - return result; - } - public async Task ShowUnsavedChangesDialog() { if (!this.UnsavedChanges) return true; @@ -176,26 +85,6 @@ public partial class MainWindowViewModel : ViewModelBase return result; } - [RelayCommand] - private void StartSearch(object? parameter) - { - if (parameter is not object[] values || values.Length < 3) return; - - var query = values[0] as string ?? string.Empty; - if (string.IsNullOrWhiteSpace(query)) return; - var mode = (TagDatabaseSearchMode)values[1]; - var searchAliases = (bool)values[2]; - - try - { - this.SearchViewModel?.Search(query, mode, searchAliases); - } - catch (Exception ex) - { - this.ShowErrorDialog(ex.Message); - } - } - [RelayCommand] private async Task CancelTagEditAsync() { @@ -306,6 +195,23 @@ public partial class MainWindowViewModel : ViewModelBase } } + [RelayCommand(AllowConcurrentExecutions = false)] + private async Task NewTag() + { + var userWantsToSave = await this.ShowUnsavedChangesDialog(); + if (userWantsToSave is null) return; + + this.SelectedTag = new TagItemViewModel( + new Tag + { + Name = string.Empty, + IsTopLevel = true, + TagBindings = this.TagDatabaseService.DefaultTagBindings + } + ); + this.UnsavedChanges = true; + } + private void OnUserEditedTag(object? sender, EventArgs e) { this.UnsavedChanges = true; @@ -344,12 +250,97 @@ public partial class MainWindowViewModel : ViewModelBase } } + [RelayCommand] + private async Task SaveSelectedTagAsync() + { + try + { + if (this.SelectedTag is null || !this.TagDatabaseService.IsDatabaseOpen || !this.IsDbEnabled) return; + + this.SelectedTag.CommitEdit(); + await this.TagDatabaseService.WriteTagsToDatabase([this.SelectedTag.Tag]); + this.SelectedTag.RefreshParentsString(); + this.StatusBlockText = string.Format(Resources.StatusBlockTagSaveSuccessful, this.SelectedTag.Name); + this.UnsavedChanges = false; + } + catch (Exception ex) + { + this.ShowErrorDialog(ex.Message); + } + } + + [RelayCommand] + private void ShowBulkAddDialog() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return; + + var dialog = new BulkAddWindow + { + DataContext = new BulkAddViewModel(this) + }; + + dialog.ShowDialog(desktop.MainWindow!); + } + + [RelayCommand] + private void ShowDatabaseSettings() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return; + + var dialog = new DatabaseSettingsWindow + { + DataContext = new DatabaseSettingsViewModel(this) + }; + + dialog.ShowDialog(desktop.MainWindow!); + } + private void ShowErrorDialog(string message) { var error = new ErrorDialogViewModel(message); error.ShowDialog(); } + [RelayCommand] + private void ShowImportDialog() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return; + + var dialog = new ImportDialog + { + DataContext = new ImportDialogViewModel(this) + }; + + dialog.ShowDialog(desktop.MainWindow!); + } + + private async Task ShowNullableBoolDialog(Window dialog) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return null; + + var mainWindow = desktop.MainWindow; + var result = await dialog.ShowDialog(mainWindow!); + return result; + } + + [RelayCommand] + private void ShowRecentsWindow() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + return; + + var dialog = new RecentsWindow + { + DataContext = new RecentsViewModel(this) + }; + + dialog.ShowDialog(desktop.MainWindow!); + } + [RelayCommand] private async Task StartExportAsync() { @@ -388,6 +379,26 @@ public partial class MainWindowViewModel : ViewModelBase } } + [RelayCommand] + private void StartSearch(object? parameter) + { + if (parameter is not object[] values || values.Length < 3) return; + + var query = values[0] as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(query)) return; + var mode = (TagDatabaseSearchMode)values[1]; + var searchAliases = (bool)values[2]; + + try + { + this.SearchViewModel?.Search(query, mode, searchAliases); + } + catch (Exception ex) + { + this.ShowErrorDialog(ex.Message); + } + } + [RelayCommand] private async Task StartTagDeletionAsync() { @@ -443,7 +454,7 @@ public partial class MainWindowViewModel : ViewModelBase this.SelectedTag?.SyncId(); this.OnPropertyChanged(nameof(this.TotalTags)); } - + if (result.Deleted.Count > 0) this.OnPropertyChanged(nameof(this.TotalTags)); } } \ No newline at end of file diff --git a/UI/ViewModels/RecentsViewModel.cs b/UI/ViewModels/RecentsViewModel.cs new file mode 100644 index 0000000..5965b47 --- /dev/null +++ b/UI/ViewModels/RecentsViewModel.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using TagHierarchyManager.Models; + +namespace TagHierarchyManager.UI.ViewModels; + +public partial class RecentsViewModel : ViewModelBase +{ + private readonly Func, List> _getParentNamesById; + private readonly MainWindowViewModel _mainWindow; + [ObservableProperty] private ObservableCollection _recentAdded = []; + [ObservableProperty] private ObservableCollection _recentEdited = []; + + public RecentsViewModel(MainWindowViewModel mainWindow) + { + this._mainWindow = mainWindow; + this._getParentNamesById = mainWindow.TagDatabaseService.GetParentNamesByIds; + this.RecentAdded = new ObservableCollection(); + foreach (var tag in this._mainWindow.TagDatabaseService.GetRecentTags(true)) + if (tag.CreatedAt is not null) + this.RecentAdded.Add(new TagRow(tag, tag.CreatedAt.Value)); + + foreach (var tag in this._mainWindow.TagDatabaseService.GetRecentTags(false)) + this.RecentEdited.Add(new TagRow(tag, tag.UpdatedAt)); + } + + public event Action? RequestClose; + + public void ActivateRowToTagItem(TagRow tagRow) + { + var tag = this._mainWindow.TagDatabaseService.GetTagById(tagRow.Id); + if (tag is null) return; + var vm = new TagItemViewModel(tag, this._getParentNamesById); + this._mainWindow.SelectedTag = vm; + this.RequestClose?.Invoke(); + } + + public class TagRow(Tag tag, DateTime dateTime) + { + public DateTime DateTime => dateTime; + public int Id => tag.Id; + public string Name => tag.Name; + } +} \ No newline at end of file diff --git a/UI/Views/BulkAddWindow.axaml b/UI/Views/BulkAddWindow.axaml index ba2e172..d1f227c 100644 --- a/UI/Views/BulkAddWindow.axaml +++ b/UI/Views/BulkAddWindow.axaml @@ -8,7 +8,7 @@ mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="450" Width="1100" Height="450" x:Class="TagHierarchyManager.UI.Views.BulkAddWindow" - Title="{Binding WindowTitle}" + Title="{x:Static assets:Resources.TitleBulkAdd}" KeyDown="BulkAddWindow_OnKeyDown"> - + VerticalAlignment="Center" /> + + Classes="TextArea ClearButton" /> - + diff --git a/UI/Views/ImportDialog.axaml b/UI/Views/ImportDialog.axaml index 48a0e44..51b6df9 100644 --- a/UI/Views/ImportDialog.axaml +++ b/UI/Views/ImportDialog.axaml @@ -8,7 +8,7 @@ Width="600" Height="200" CanResize="False" ShowInTaskbar="False" x:Class="TagHierarchyManager.UI.Views.ImportDialog" x:DataType="vm:ImportDialogViewModel" - Title="{x:Static assets:Resources.ImportDialogTitle}"> + Title="{x:Static assets:Resources.TitlemportDialog}"> diff --git a/UI/Views/MainWindow.axaml b/UI/Views/MainWindow.axaml index 430a067..ce3f5be 100644 --- a/UI/Views/MainWindow.axaml +++ b/UI/Views/MainWindow.axaml @@ -19,7 +19,7 @@ + Command="{Binding ShowImportDialogCommand}" /> @@ -32,6 +32,8 @@ Header="{x:Static assets:Resources.MenuDatabase}"> + - + - - - + + + @@ -175,7 +177,7 @@ - + diff --git a/UI/Views/MainWindow.axaml.cs b/UI/Views/MainWindow.axaml.cs index 438c5e3..2f97846 100644 --- a/UI/Views/MainWindow.axaml.cs +++ b/UI/Views/MainWindow.axaml.cs @@ -6,10 +6,6 @@ using TagHierarchyManager.UI.ViewModels; namespace TagHierarchyManager.UI.Views; -/* - * TODO: - * add "recently added" functionality. - */ public partial class MainWindow : Window { private bool _userWantsToQuit; @@ -20,28 +16,26 @@ public partial class MainWindow : Window } private MainWindowViewModel? ViewModel => this.DataContext as MainWindowViewModel; - - public void MenuItemQuit_Click(object? sender, RoutedEventArgs e) - { - this.Close(); - } - + public void MenuItemAbout_Click(object? sender, RoutedEventArgs e) { new AboutWindow().ShowDialog(this); } + public void MenuItemQuit_Click(object? sender, RoutedEventArgs e) => + this.Close(); + public void SearchTextBox_OnKeyDown(object? sender, KeyEventArgs e) { if (e.Key != Key.Enter) return; - - var args = new object[] - { - this.SearchTextBox.Text ?? "", - this.SearchModeComboBox.SelectedIndex, - this.SearchAliasesCheckBox.IsChecked ?? false + + var args = new object[] + { + this.SearchTextBox.Text ?? "", + this.SearchModeComboBox.SelectedIndex, + this.SearchAliasesCheckBox.IsChecked ?? false }; - + if (this.ViewModel?.StartSearchCommand.CanExecute(args) != true) return; this.ViewModel.StartSearchCommand.Execute(args); e.Handled = true; @@ -53,7 +47,6 @@ public partial class MainWindow : Window { if (this._userWantsToQuit) return; - if (this.ViewModel is not null && this.ViewModel.UnsavedChanges) { e.Cancel = true; diff --git a/UI/Views/RecentsWindow.axaml b/UI/Views/RecentsWindow.axaml new file mode 100644 index 0000000..2aea177 --- /dev/null +++ b/UI/Views/RecentsWindow.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UI/Views/RecentsWindow.axaml.cs b/UI/Views/RecentsWindow.axaml.cs new file mode 100644 index 0000000..7a57148 --- /dev/null +++ b/UI/Views/RecentsWindow.axaml.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using TagHierarchyManager.UI.ViewModels; + +namespace TagHierarchyManager.UI.Views; + +public partial class RecentsWindow : Window +{ + public RecentsWindow() + { + this.InitializeComponent(); + + this.DataContextChanged += (_, _) => + { + if (this.ViewModel != null) this.ViewModel.RequestClose += this.Close; + }; + + this.Unloaded += (_, _) => + { + if (this.ViewModel != null) + this.ViewModel.RequestClose -= this.Close; + }; + } + + private RecentsViewModel? ViewModel => this.DataContext as RecentsViewModel; + + public void DataGrid_DoubleTapped(object sender, RoutedEventArgs e) + { + if (e.Source is Control control && control.DataContext is RecentsViewModel.TagRow row) + this.ViewModel.ActivateRowToTagItem(row); + } +} \ No newline at end of file