diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs new file mode 100644 index 0000000..330cdcb --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Core.Exceptions; +using DiscordChatExporter.Core.Exporting; +using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Services; +using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.ViewModels.Dialogs; +using DiscordChatExporter.Gui.ViewModels.Messages; +using DiscordChatExporter.Gui.ViewModels.Framework; +using Gress; +using Gress.Completable; +using Stylet; + +namespace DiscordChatExporter.Gui.ViewModels.Components; + +public class DashboardViewModel : PropertyChangedBase +{ + private readonly IViewModelFactory _viewModelFactory; + private readonly IEventAggregator _eventAggregator; + private readonly DialogManager _dialogManager; + private readonly SettingsService _settingsService; + + private readonly AutoResetProgressMuxer _progressMuxer; + + private DiscordClient? _discord; + + public bool IsBusy { get; private set; } + + public ProgressContainer Progress { get; } = new(); + + public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; + + public string? Token { get; set; } + + private IReadOnlyDictionary>? GuildChannelMap { get; set; } + + public IReadOnlyList? AvailableGuilds => GuildChannelMap?.Keys.ToArray(); + + public Guild? SelectedGuild { get; set; } + + public IReadOnlyList? AvailableChannels => SelectedGuild is not null + ? GuildChannelMap?[SelectedGuild] + : null; + + public IReadOnlyList? SelectedChannels { get; set; } + + public DashboardViewModel( + IViewModelFactory viewModelFactory, + IEventAggregator eventAggregator, + DialogManager dialogManager, + SettingsService settingsService) + { + _viewModelFactory = viewModelFactory; + _eventAggregator = eventAggregator; + _dialogManager = dialogManager; + _settingsService = settingsService; + + _progressMuxer = Progress.CreateMuxer().WithAutoReset(); + + this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); + Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); + } + + public void OnViewFullyLoaded() + { + if (_settingsService.LastToken is not null) + { + Token = _settingsService.LastToken; + } + } + + public async void ShowSettings() + { + var dialog = _viewModelFactory.CreateSettingsViewModel(); + await _dialogManager.ShowDialogAsync(dialog); + } + + public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); + + public bool CanPopulateGuildsAndChannels => + !IsBusy && !string.IsNullOrWhiteSpace(Token); + + public async void PopulateGuildsAndChannels() + { + IsBusy = true; + var progress = _progressMuxer.CreateInput(); + + try + { + var token = Token?.Trim('"', ' '); + if (string.IsNullOrWhiteSpace(token)) + return; + + _settingsService.LastToken = token; + + var discord = new DiscordClient(token); + + var guildChannelMap = new Dictionary>(); + await foreach (var guild in discord.GetUserGuildsAsync()) + { + var channels = await discord.GetGuildChannelsAsync(guild.Id); + guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray(); + } + + _discord = discord; + GuildChannelMap = guildChannelMap; + SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + } + catch (Exception ex) + { + var dialog = _viewModelFactory.CreateMessageBoxViewModel( + "Error pulling guilds and channels", + ex.ToString() + ); + + await _dialogManager.ShowDialogAsync(dialog); + } + finally + { + progress.ReportCompletion(); + IsBusy = false; + } + } + + public bool CanExportChannels => + !IsBusy && + _discord is not null && + SelectedGuild is not null && + SelectedChannels is not null && + SelectedChannels.Any(); + + public async void ExportChannels() + { + IsBusy = true; + + try + { + if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any()) + return; + + var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); + if (await _dialogManager.ShowDialogAsync(dialog) != true) + return; + + var exporter = new ChannelExporter(_discord); + + var progresses = Enumerable + .Range(0, dialog.Channels!.Count) + .Select(_ => _progressMuxer.CreateInput()) + .ToArray(); + + var successfulExportCount = 0; + + await Parallel.ForEachAsync( + dialog.Channels.Zip(progresses), + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit) + }, + async (tuple, cancellationToken) => + { + var (channel, progress) = tuple; + + try + { + var request = new ExportRequest( + dialog.Guild!, + channel, + dialog.OutputPath!, + dialog.SelectedFormat, + dialog.After?.Pipe(Snowflake.FromDate), + dialog.Before?.Pipe(Snowflake.FromDate), + dialog.PartitionLimit, + dialog.MessageFilter, + dialog.ShouldDownloadMedia, + _settingsService.ShouldReuseMedia, + _settingsService.DateFormat + ); + + await exporter.ExportChannelAsync(request, progress, cancellationToken); + + Interlocked.Increment(ref successfulExportCount); + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + } + finally + { + progress.ReportCompletion(); + } + } + ); + + // Notify of overall completion + if (successfulExportCount > 0) + { + _eventAggregator.Publish( + new NotificationMessage($"Successfully exported {successfulExportCount} channel(s)") + ); + } + } + catch (Exception ex) + { + var dialog = _viewModelFactory.CreateMessageBoxViewModel( + "Error exporting channel(s)", + ex.ToString() + ); + + await _dialogManager.ShowDialogAsync(dialog); + } + finally + { + IsBusy = false; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs b/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs index d5e086d..a897981 100644 --- a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs +++ b/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs @@ -1,10 +1,13 @@ -using DiscordChatExporter.Gui.ViewModels.Dialogs; +using DiscordChatExporter.Gui.ViewModels.Components; +using DiscordChatExporter.Gui.ViewModels.Dialogs; namespace DiscordChatExporter.Gui.ViewModels.Framework; // Used to instantiate new view models while making use of dependency injection public interface IViewModelFactory { + DashboardViewModel CreateDashboardViewModel(); + ExportSetupViewModel CreateExportSetupViewModel(); MessageBoxViewModel CreateMessageBoxViewModel(); diff --git a/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs b/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs new file mode 100644 index 0000000..099bbb2 --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs @@ -0,0 +1,8 @@ +namespace DiscordChatExporter.Gui.ViewModels.Messages; + +public class NotificationMessage +{ + public string Text { get; } + + public NotificationMessage(string text) => Text = text; +} \ No newline at end of file diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs index c23175e..3f338b1 100644 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs @@ -1,59 +1,30 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; using System.Threading.Tasks; -using DiscordChatExporter.Core.Discord; -using DiscordChatExporter.Core.Discord.Data; -using DiscordChatExporter.Core.Exceptions; -using DiscordChatExporter.Core.Exporting; -using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Dialogs; +using DiscordChatExporter.Gui.ViewModels.Messages; using DiscordChatExporter.Gui.ViewModels.Framework; -using Gress; -using Gress.Completable; using MaterialDesignThemes.Wpf; using Stylet; namespace DiscordChatExporter.Gui.ViewModels; -public class RootViewModel : Screen +public class RootViewModel : Screen, IHandle, IDisposable { private readonly IViewModelFactory _viewModelFactory; private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; private readonly UpdateService _updateService; - - private readonly AutoResetProgressMuxer _progressMuxer; - - private DiscordClient? _discord; - - public bool IsBusy { get; private set; } - - public ProgressContainer Progress { get; } = new(); - - public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; - + public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5)); - - public string? Token { get; set; } - - private IReadOnlyDictionary>? GuildChannelMap { get; set; } - - public IReadOnlyList? AvailableGuilds => GuildChannelMap?.Keys.ToArray(); - - public Guild? SelectedGuild { get; set; } - - public IReadOnlyList? AvailableChannels => SelectedGuild is not null - ? GuildChannelMap?[SelectedGuild] - : null; - - public IReadOnlyList? SelectedChannels { get; set; } + + public DashboardViewModel Dashboard { get; } public RootViewModel( IViewModelFactory viewModelFactory, + IEventAggregator eventAggregator, DialogManager dialogManager, SettingsService settingsService, UpdateService updateService) @@ -62,13 +33,12 @@ public class RootViewModel : Screen _dialogManager = dialogManager; _settingsService = settingsService; _updateService = updateService; - - _progressMuxer = Progress.CreateMuxer().WithAutoReset(); + + eventAggregator.Subscribe(this); + + Dashboard = _viewModelFactory.CreateDashboardViewModel(); DisplayName = $"{App.Name} v{App.VersionString}"; - - this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); - Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); } private async Task ShowWarInUkraineMessageAsync() @@ -115,23 +85,18 @@ Press LEARN MORE to find ways that you can help.".Trim(), } } - // This is a custom event that fires when the dialog host is loaded public async void OnViewFullyLoaded() { await ShowWarInUkraineMessageAsync(); + await CheckForUpdatesAsync(); } - protected override async void OnViewLoaded() + protected override void OnViewLoaded() { base.OnViewLoaded(); _settingsService.Load(); - if (_settingsService.LastToken is not null) - { - Token = _settingsService.LastToken; - } - if (_settingsService.IsDarkModeEnabled) { App.SetDarkTheme(); @@ -140,8 +105,6 @@ Press LEARN MORE to find ways that you can help.".Trim(), { App.SetLightTheme(); } - - await CheckForUpdatesAsync(); } protected override void OnClose() @@ -152,149 +115,8 @@ Press LEARN MORE to find ways that you can help.".Trim(), _updateService.FinalizeUpdate(false); } - public async void ShowSettings() - { - var dialog = _viewModelFactory.CreateSettingsViewModel(); - await _dialogManager.ShowDialogAsync(dialog); - } - - public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); - - public bool CanPopulateGuildsAndChannels => - !IsBusy && !string.IsNullOrWhiteSpace(Token); - - public async void PopulateGuildsAndChannels() - { - IsBusy = true; - var progress = _progressMuxer.CreateInput(); - - try - { - var token = Token?.Trim('"', ' '); - if (string.IsNullOrWhiteSpace(token)) - return; - - _settingsService.LastToken = token; - - var discord = new DiscordClient(token); - - var guildChannelMap = new Dictionary>(); - await foreach (var guild in discord.GetUserGuildsAsync()) - { - var channels = await discord.GetGuildChannelsAsync(guild.Id); - guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray(); - } - - _discord = discord; - GuildChannelMap = guildChannelMap; - SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); - } - catch (DiscordChatExporterException ex) when (!ex.IsFatal) - { - Notifications.Enqueue(ex.Message.TrimEnd('.')); - } - catch (Exception ex) - { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Error pulling guilds and channels", - ex.ToString() - ); - - await _dialogManager.ShowDialogAsync(dialog); - } - finally - { - progress.ReportCompletion(); - IsBusy = false; - } - } - - public bool CanExportChannels => - !IsBusy && - _discord is not null && - SelectedGuild is not null && - SelectedChannels is not null && - SelectedChannels.Any(); - - public async void ExportChannels() - { - IsBusy = true; - - try - { - if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any()) - return; - - var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); - if (await _dialogManager.ShowDialogAsync(dialog) != true) - return; - - var exporter = new ChannelExporter(_discord); + public void Handle(NotificationMessage message) => + Notifications.Enqueue(message.Text); - var progresses = Enumerable - .Range(0, dialog.Channels!.Count) - .Select(_ => _progressMuxer.CreateInput()) - .ToArray(); - - var successfulExportCount = 0; - - await Parallel.ForEachAsync( - dialog.Channels.Zip(progresses), - new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit) - }, - async (tuple, cancellationToken) => - { - var (channel, progress) = tuple; - - try - { - var request = new ExportRequest( - dialog.Guild!, - channel, - dialog.OutputPath!, - dialog.SelectedFormat, - dialog.After?.Pipe(Snowflake.FromDate), - dialog.Before?.Pipe(Snowflake.FromDate), - dialog.PartitionLimit, - dialog.MessageFilter, - dialog.ShouldDownloadMedia, - _settingsService.ShouldReuseMedia, - _settingsService.DateFormat - ); - - await exporter.ExportChannelAsync(request, progress, cancellationToken); - - Interlocked.Increment(ref successfulExportCount); - } - catch (DiscordChatExporterException ex) when (!ex.IsFatal) - { - Notifications.Enqueue(ex.Message.TrimEnd('.')); - } - finally - { - progress.ReportCompletion(); - } - } - ); - - // Notify of overall completion - if (successfulExportCount > 0) - Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)"); - } - catch (Exception ex) - { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Error exporting channel(s)", - ex.ToString() - ); - - await _dialogManager.ShowDialogAsync(dialog); - } - finally - { - IsBusy = false; - } - } + public void Dispose() => Notifications.Dispose(); } \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml new file mode 100644 index 0000000..3fe8cc9 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml @@ -0,0 +1,359 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wiki + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs new file mode 100644 index 0000000..348c8a7 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs @@ -0,0 +1,9 @@ +namespace DiscordChatExporter.Gui.Views.Components; + +public partial class DashboardView +{ + public DashboardView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml b/DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml index 462c0fe..e96789c 100644 --- a/DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml +++ b/DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml @@ -7,7 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:s="https://github.com/canton7/Stylet" xmlns:system="clr-namespace:System;assembly=System.Runtime" - MinWidth="500" + Width="500" d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}" Style="{DynamicResource MaterialDesignRoot}" mc:Ignorable="d"> diff --git a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml index 1609ce3..fe73971 100644 --- a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml +++ b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml @@ -6,7 +6,7 @@ xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:s="https://github.com/canton7/Stylet" - Width="310" + Width="380" d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}" Style="{DynamicResource MaterialDesignRoot}" mc:Ignorable="d"> diff --git a/DiscordChatExporter.Gui/Views/RootView.xaml b/DiscordChatExporter.Gui/Views/RootView.xaml index d4309c6..7eb3b0d 100644 --- a/DiscordChatExporter.Gui/Views/RootView.xaml +++ b/DiscordChatExporter.Gui/Views/RootView.xaml @@ -2,11 +2,7 @@ x:Class="DiscordChatExporter.Gui.Views.RootView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors" - xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase" - xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:i="http://schemas.microsoft.com/xaml/behaviors" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:s="https://github.com/canton7/Stylet" @@ -16,362 +12,21 @@ MinWidth="325" d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}" Background="{DynamicResource MaterialDesignPaper}" - FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}" Icon="/DiscordChatExporter;component/favicon.ico" Style="{DynamicResource MaterialDesignRoot}" WindowStartupLocation="CenterScreen" mc:Ignorable="d"> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wiki - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + \ No newline at end of file