Migrate to Stylet and refactor view/view-model framework

This commit is contained in:
Alexey Golub 2018-11-29 19:18:44 +02:00
parent 083bdef419
commit 0d3510222e
49 changed files with 672 additions and 921 deletions

View file

@ -1,26 +1,24 @@
using CommonServiceLocator; using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services; using StyletIoC;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Cli namespace DiscordChatExporter.Cli
{ {
public class Container public static class Container
{ {
public Container() public static IContainer Instance { get; }
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services static Container()
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
}
public T Resolve<T>(string key = null)
{ {
return ServiceLocator.Current.GetInstance<T>(key); var builder = new StyletIoCBuilder();
// Autobind services in the .Core assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Set instance
Instance = builder.BuildContainer();
} }
} }
} }

View file

@ -10,9 +10,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.2.1" /> <PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="CommonServiceLocator" Version="2.0.3" /> <PackageReference Include="Stylet" Version="1.1.22" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup> </ItemGroup>

View file

@ -18,10 +18,9 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
// Get services // Get services
var container = new Container(); var settingsService = Container.Instance.Get<SettingsService>();
var settingsService = container.Resolve<ISettingsService>(); var dataService = Container.Instance.Get<DataService>();
var dataService = container.Resolve<IDataService>(); var exportService = Container.Instance.Get<ExportService>();
var exportService = container.Resolve<IExportService>();
// Configure settings // Configure settings
if (Options.DateFormat.IsNotBlank()) if (Options.DateFormat.IsNotBlank())

View file

@ -18,8 +18,7 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
// Get data service // Get data service
var container = new Container(); var dataService = Container.Instance.Get<DataService>();
var dataService = container.Resolve<IDataService>();
// Get channels // Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId); var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);

View file

@ -16,8 +16,7 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
// Get data service // Get data service
var container = new Container(); var dataService = Container.Instance.Get<DataService>();
var dataService = container.Resolve<IDataService>();
// Get channels // Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken()); var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());

View file

@ -16,8 +16,7 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
// Get data service // Get data service
var container = new Container(); var dataService = Container.Instance.Get<DataService>();
var dataService = container.Resolve<IDataService>();
// Get guilds // Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken()); var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());

View file

@ -21,10 +21,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="Onova" Version="2.1.0" /> <PackageReference Include="Onova" Version="2.2.0" />
<PackageReference Include="Polly" Version="6.0.1" /> <PackageReference Include="Polly" Version="6.0.1" />
<PackageReference Include="Scriban" Version="1.2.1" /> <PackageReference Include="Scriban" Version="1.2.7" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup> </ItemGroup>

View file

@ -13,7 +13,7 @@ using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public partial class DataService : IDataService, IDisposable public partial class DataService : IDisposable
{ {
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();

View file

@ -7,11 +7,11 @@ using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public partial class ExportService : IExportService public partial class ExportService
{ {
private readonly ISettingsService _settingsService; private readonly SettingsService _settingsService;
public ExportService(ISettingsService settingsService) public ExportService(SettingsService settingsService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
} }

View file

@ -1,34 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IDataService
{
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages);
Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
}
}

View file

@ -1,10 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null);
}
}

View file

@ -1,19 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface ISettingsService
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
AuthToken LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
int? LastPartitionLimit { get; set; }
void Load();
void Save();
}
}

View file

@ -1,14 +0,0 @@
using System;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Services
{
public interface IUpdateService
{
bool NeedRestart { get; set; }
Task<Version> CheckPrepareUpdateAsync();
void FinalizeUpdate();
}
}

View file

@ -3,7 +3,7 @@ using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public class SettingsService : SettingsManager, ISettingsService public class SettingsService : SettingsManager
{ {
public bool IsAutoUpdateEnabled { get; set; } = true; public bool IsAutoUpdateEnabled { get; set; } = true;

View file

@ -5,23 +5,20 @@ using Onova.Services;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public class UpdateService : IUpdateService public class UpdateService
{ {
private readonly ISettingsService _settingsService; private readonly SettingsService _settingsService;
private readonly IUpdateManager _manager;
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
private Version _updateVersion; private Version _updateVersion;
private bool _updateFinalized; private bool _updaterLaunched;
public bool NeedRestart { get; set; } public UpdateService(SettingsService settingsService)
public UpdateService(ISettingsService settingsService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
_manager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
} }
public async Task<Version> CheckPrepareUpdateAsync() public async Task<Version> CheckPrepareUpdateAsync()
@ -31,33 +28,33 @@ namespace DiscordChatExporter.Core.Services
return null; return null;
// Cleanup leftover files // Cleanup leftover files
_manager.Cleanup(); _updateManager.Cleanup();
// Check for updates // Check for updates
var check = await _manager.CheckForUpdatesAsync(); var check = await _updateManager.CheckForUpdatesAsync();
if (!check.CanUpdate) if (!check.CanUpdate)
return null; return null;
// Prepare the update // Prepare the update
if (!_manager.IsUpdatePrepared(check.LastVersion)) if (!_updateManager.IsUpdatePrepared(check.LastVersion))
await _manager.PrepareUpdateAsync(check.LastVersion); await _updateManager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion; return _updateVersion = check.LastVersion;
} }
public void FinalizeUpdate() public void FinalizeUpdate(bool needRestart)
{ {
// Check if an update is pending // Check if an update is pending
if (_updateVersion == null) if (_updateVersion == null)
return; return;
// Check if the update has already been finalized // Check if the updater has already been launched
if (_updateFinalized) if (_updaterLaunched)
return; return;
// Launch the updater // Launch the updater
_manager.LaunchUpdater(_updateVersion, NeedRestart); _updateManager.LaunchUpdater(_updateVersion, needRestart);
_updateFinalized = true; _updaterLaunched = true;
} }
} }
} }

View file

@ -2,16 +2,19 @@
x:Class="DiscordChatExporter.Gui.App" x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:local="clr-namespace:DiscordChatExporter.Gui" xmlns:local="clr-namespace:DiscordChatExporter.Gui"
DispatcherUnhandledException="App_OnDispatcherUnhandledException" xmlns:s="https://github.com/canton7/Stylet">
StartupUri="Views/MainWindow.xaml">
<Application.Resources> <Application.Resources>
<ResourceDictionary> <s:ApplicationLoader>
<!-- Bootstrapper -->
<s:ApplicationLoader.Bootstrapper>
<local:Bootstrapper />
</s:ApplicationLoader.Bootstrapper>
<!-- Merged dictionaries -->
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<!-- Colors --> <!-- Colors -->
@ -110,11 +113,6 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<!-- Converters --> </s:ApplicationLoader>
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
<!-- Container -->
<local:Container x:Key="Container" />
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>

View file

@ -1,13 +1,6 @@
using System.Windows; namespace DiscordChatExporter.Gui
using System.Windows.Threading;
namespace DiscordChatExporter.Gui
{ {
public partial class App public partial class App
{ {
private void App_OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs args)
{
MessageBox.Show(args.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
} }
} }

View file

@ -0,0 +1,34 @@
using System.Windows;
using System.Windows.Threading;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Stylet;
using StyletIoC;
namespace DiscordChatExporter.Gui
{
public class Bootstrapper : Bootstrapper<RootViewModel>
{
protected override void ConfigureIoC(IStyletIoCBuilder builder)
{
base.ConfigureIoC(builder);
// Autobind services in the .Core assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Bind view model factory
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{
base.OnUnhandledException(e);
MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}

View file

@ -1,36 +0,0 @@
using CommonServiceLocator;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Gui
{
public class Container
{
public IExportSetupViewModel ExportSetupViewModel => Resolve<IExportSetupViewModel>();
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
public Container()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
// View models
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
}
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
}
}

View file

@ -8,10 +8,12 @@ namespace DiscordChatExporter.Gui.Converters
[ValueConversion(typeof(ExportFormat), typeof(string))] [ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter public class ExportFormatToStringConverter : IValueConverter
{ {
public static ExportFormatToStringConverter Instance { get; } = new ExportFormatToStringConverter();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{ {
var format = (ExportFormat) value; var format = (ExportFormat?) value;
return format.GetDisplayName(); return format?.GetDisplayName();
} }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)

View file

@ -56,26 +56,22 @@
<Compile Include="App.xaml.cs"> <Compile Include="App.xaml.cs">
<DependentUpon>App.xaml</DependentUpon> <DependentUpon>App.xaml</DependentUpon>
</Compile> </Compile>
<Compile Include="Bootstrapper.cs" />
<Compile Include="Converters\ExportFormatToStringConverter.cs" /> <Compile Include="Converters\ExportFormatToStringConverter.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" /> <Compile Include="ViewModels\Dialogs\ExportSetupViewModel.cs" />
<Compile Include="Messages\ShowNotificationMessage.cs" /> <Compile Include="ViewModels\Framework\DialogManager.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" /> <Compile Include="ViewModels\Framework\DialogScreen.cs" />
<Compile Include="Messages\StartExportMessage.cs" /> <Compile Include="ViewModels\Framework\IViewModelFactory.cs" />
<Compile Include="ViewModels\ExportSetupViewModel.cs" /> <Compile Include="ViewModels\Dialogs\SettingsViewModel.cs" />
<Compile Include="ViewModels\IExportSetupViewModel.cs" /> <Compile Include="ViewModels\RootViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" /> <Compile Include="Views\Dialogs\ExportSetupView.xaml.cs">
<Compile Include="ViewModels\SettingsViewModel.cs" /> <DependentUpon>ExportSetupView.xaml</DependentUpon>
<Compile Include="Container.cs" />
<Compile Include="ViewModels\IMainViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="Views\ExportSetupDialog.xaml.cs">
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
</Compile> </Compile>
<Compile Include="Views\MainWindow.xaml.cs"> <Compile Include="Views\RootView.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon> <DependentUpon>RootView.xaml</DependentUpon>
</Compile> </Compile>
<Compile Include="Views\SettingsDialog.xaml.cs"> <Compile Include="Views\Dialogs\SettingsView.xaml.cs">
<DependentUpon>SettingsDialog.xaml</DependentUpon> <DependentUpon>SettingsView.xaml</DependentUpon>
</Compile> </Compile>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -91,7 +87,6 @@
<Generator>ResXFileCodeGenerator</Generator> <Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput> <LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource> </EmbeddedResource>
<None Include="app.config" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Resource Include="..\favicon.ico" /> <Resource Include="..\favicon.ico" />
@ -107,38 +102,36 @@
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType> <SubType>Designer</SubType>
</ApplicationDefinition> </ApplicationDefinition>
<Page Include="Views\ExportSetupDialog.xaml"> <Page Include="Views\Dialogs\ExportSetupView.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
<Page Include="Views\MainWindow.xaml"> <Page Include="Views\RootView.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
<Page Include="Views\SettingsDialog.xaml"> <Page Include="Views\Dialogs\SettingsView.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommonServiceLocator">
<Version>2.0.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignColors"> <PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version> <Version>1.1.3</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MaterialDesignThemes"> <PackageReference Include="MaterialDesignThemes">
<Version>2.4.0.1044</Version> <Version>2.5.0.1205</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MvvmLightLibs"> <PackageReference Include="PropertyChanged.Fody">
<Version>5.4.1</Version> <Version>2.6.0</Version>
</PackageReference>
<PackageReference Include="Stylet">
<Version>1.1.22</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Tyrrrz.Extensions"> <PackageReference Include="Tyrrrz.Extensions">
<Version>1.5.1</Version> <Version>1.5.1</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Tyrrrz.WpfExtensions">
<Version>1.0.5</Version>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project> </Project>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<PropertyChanged />
</Weavers>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuild. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="PropertyChanged" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="InjectOnPropertyNameChanged" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EventInvokerNames" type="xs:string">
<xs:annotation>
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEquality" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEqualityUsingBaseEquals" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseStaticEqualsFromBase" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the static Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification on the target assembly after all weavers have been finished.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View file

@ -1,17 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Messages
{
public class ShowExportSetupMessage
{
public Guild Guild { get; }
public Channel Channel { get; }
public ShowExportSetupMessage(Guild guild, Channel channel)
{
Guild = guild;
Channel = channel;
}
}
}

View file

@ -1,25 +0,0 @@
using System;
namespace DiscordChatExporter.Gui.Messages
{
public class ShowNotificationMessage
{
public string Message { get; }
public string CallbackCaption { get; }
public Action Callback { get; }
public ShowNotificationMessage(string message)
{
Message = message;
}
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
: this(message)
{
CallbackCaption = callbackCaption;
Callback = callback;
}
}
}

View file

@ -1,6 +0,0 @@
namespace DiscordChatExporter.Gui.Messages
{
public class ShowSettingsMessage
{
}
}

View file

@ -1,31 +0,0 @@
using System;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Messages
{
public class StartExportMessage
{
public Channel Channel { get; }
public string FilePath { get; }
public ExportFormat Format { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public int? PartitionLimit { get; }
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to, int? partitionLimit)
{
Channel = channel;
FilePath = filePath;
Format = format;
From = from;
To = to;
PartitionLimit = partitionLimit;
}
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
public class ExportSetupViewModel : DialogScreen
{
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
public Guild Guild { get; set; }
public Channel Channel { get; set; }
public string FilePath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat { get; set; } = ExportFormat.HtmlDark;
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public int? PartitionLimit { get; set; }
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
{
_dialogManager = dialogManager;
_settingsService = settingsService;
}
protected override void OnViewLoaded()
{
base.OnViewLoaded();
// Persist preferences
SelectedFormat = _settingsService.LastExportFormat;
PartitionLimit = _settingsService.LastPartitionLimit;
}
public void Confirm()
{
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimit = PartitionLimit;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// Generate default file name
var ext = SelectedFormat.GetFileExtension();
var defaultFileName = $"{Guild.Name} - {Channel.Name}.{ext}".Replace(Path.GetInvalidFileNameChars(), '_');
// Prompt for output file path
var filter = $"{ext.ToUpperInvariant()} files|*.{ext}";
FilePath = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
// If canceled - return
if (FilePath.IsBlank())
return;
// Close dialog
Close(true);
}
}
}

View file

@ -1,12 +1,12 @@
using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight; using DiscordChatExporter.Gui.ViewModels.Framework;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{ {
public class SettingsViewModel : ViewModelBase, ISettingsViewModel public class SettingsViewModel : DialogScreen
{ {
private readonly ISettingsService _settingsService; private readonly SettingsService _settingsService;
public bool IsAutoUpdateEnabled public bool IsAutoUpdateEnabled
{ {
@ -26,7 +26,7 @@ namespace DiscordChatExporter.Gui.ViewModels
set => _settingsService.MessageGroupLimit = value.ClampMin(0); set => _settingsService.MessageGroupLimit = value.ClampMin(0);
} }
public SettingsViewModel(ISettingsService settingsService) public SettingsViewModel(SettingsService settingsService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
} }

View file

@ -1,113 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
{
private readonly ISettingsService _settingsService;
private string _filePath;
private ExportFormat _format;
private DateTime? _from;
private DateTime? _to;
private int? _partitionLimit;
public Guild Guild { get; private set; }
public Channel Channel { get; private set; }
public string FilePath
{
get => _filePath;
set
{
Set(ref _filePath, value);
ExportCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat
{
get => _format;
set
{
Set(ref _format, value);
// Replace extension in path
var ext = value.GetFileExtension();
if (FilePath != null)
FilePath = Path.ChangeExtension(FilePath, ext);
}
}
public DateTime? From
{
get => _from;
set => Set(ref _from, value);
}
public DateTime? To
{
get => _to;
set => Set(ref _to, value);
}
public int? PartitionLimit
{
get => _partitionLimit;
set => Set(ref _partitionLimit, value);
}
// Commands
public RelayCommand ExportCommand { get; }
public ExportSetupViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
// Messages
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
{
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
PartitionLimit = _settingsService.LastPartitionLimit;
});
}
private void Export()
{
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimit = PartitionLimit;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To, PartitionLimit));
}
}
}

View file

@ -0,0 +1,58 @@
using System.IO;
using System.Threading.Tasks;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public class DialogManager
{
private readonly IViewManager _viewManager;
public DialogManager(IViewManager viewManager)
{
_viewManager = viewManager;
}
public async Task<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{
// Get the view that renders this viewmodel
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
DialogOpenedEventHandler onDialogOpened = (sender, e) =>
{
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object o, CloseEventArgs args)
{
e.Session.Close();
dialogScreen.Closed -= OnScreenClosed;
}
dialogScreen.Closed += OnScreenClosed;
};
// Show view
await DialogHost.Show(view, onDialogOpened);
// Return the result
return dialogScreen.DialogResult;
}
public string PromptSaveFilePath(string filter = "All files|*.*", string initialFilePath = "")
{
// Create dialog
var dialog = new SaveFileDialog
{
Filter = filter,
AddExtension = true,
FileName = initialFilePath,
DefaultExt = Path.GetExtension(initialFilePath) ?? ""
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
}
}

View file

@ -0,0 +1,26 @@
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public abstract class DialogScreen<T> : Screen
{
public T DialogResult { get; private set; }
public void Close(T dialogResult = default(T))
{
// Set the result
DialogResult = dialogResult;
// If there is a parent - ask them to close this dialog
if (Parent != null)
RequestClose(Equals(dialogResult, default(T)));
// Otherwise close ourselves
else
((IScreenState) this).Close();
}
}
public abstract class DialogScreen : DialogScreen<bool?>
{
}
}

View file

@ -0,0 +1,12 @@
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
{
ExportSetupViewModel CreateExportSetupViewModel();
SettingsViewModel CreateSettingsViewModel();
}
}

View file

@ -1,21 +0,0 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IExportSetupViewModel
{
Guild Guild { get; }
Channel Channel { get; }
string FilePath { get; set; }
IReadOnlyList<ExportFormat> AvailableFormats { get; }
ExportFormat SelectedFormat { get; set; }
DateTime? From { get; set; }
DateTime? To { get; set; }
int? PartitionLimit { get; set; }
RelayCommand ExportCommand { get; }
}
}

View file

@ -1,29 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IMainViewModel
{
bool IsBusy { get; }
bool IsDataAvailable { get; }
bool IsProgressIndeterminate { get; }
double Progress { get; }
bool IsBotToken { get; set; }
string TokenValue { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; }
IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand ViewLoadedCommand { get; }
RelayCommand ViewClosedCommand { get; }
RelayCommand PullDataCommand { get; }
RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; }
RelayCommand<Channel> ShowExportSetupCommand { get; }
}
}

View file

@ -1,10 +0,0 @@
namespace DiscordChatExporter.Gui.ViewModels
{
public interface ISettingsViewModel
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
}

View file

@ -1,280 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Windows;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class MainViewModel : ViewModelBase, IMainViewModel
{
private readonly ISettingsService _settingsService;
private readonly IUpdateService _updateService;
private readonly IDataService _dataService;
private readonly IExportService _exportService;
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private double _progress;
private bool _isBotToken;
private string _tokenValue;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels;
public bool IsBusy
{
get => _isBusy;
private set
{
Set(ref _isBusy, value);
PullDataCommand.RaiseCanExecuteChanged();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
public bool IsProgressIndeterminate => Progress <= 0;
public double Progress
{
get => _progress;
private set
{
Set(ref _progress, value);
RaisePropertyChanged(() => IsProgressIndeterminate);
}
}
public bool IsBotToken
{
get => _isBotToken;
set => Set(ref _isBotToken, value);
}
public string TokenValue
{
get => _tokenValue;
set
{
// Remove invalid chars
value = value?.Trim('"');
Set(ref _tokenValue, value);
PullDataCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Guild> AvailableGuilds
{
get => _availableGuilds;
private set
{
Set(ref _availableGuilds, value);
RaisePropertyChanged(() => IsDataAvailable);
}
}
public Guild SelectedGuild
{
get => _selectedGuild;
set
{
Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Channel> AvailableChannels
{
get => _availableChannels;
private set => Set(ref _availableChannels, value);
}
public RelayCommand ViewLoadedCommand { get; }
public RelayCommand ViewClosedCommand { get; }
public RelayCommand PullDataCommand { get; }
public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
IExportService exportService)
{
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_exportService = exportService;
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
// Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages
MessengerInstance.Register<StartExportMessage>(this,
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To, m.PartitionLimit));
}
private async void ViewLoaded()
{
// Load settings
_settingsService.Load();
// Get last token
if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
MessengerInstance.Send(new ShowNotificationMessage(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.NeedRestart = true;
Application.Current.Shutdown();
}));
}
}
catch
{
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
}
}
private void ViewClosed()
{
// Save settings
_settingsService.Save();
// Finalize updates if available
_updateService.FinalizeUpdate();
}
private async void PullData()
{
IsBusy = true;
// Create token
var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token
_settingsService.LastToken = token;
// Clear existing
_guildChannelsMap.Clear();
try
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
.OrderBy(c => c.Name)
.ToArray();
}
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized make sure the token is valid"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("Forbidden account may be locked by 2FA"));
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
SelectedGuild = AvailableGuilds.FirstOrDefault();
IsBusy = false;
}
private void ShowSettings()
{
MessengerInstance.Send(new ShowSettingsMessage());
}
private void ShowAbout()
{
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
}
private void ShowExportSetup(Channel channel)
{
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
}
private async void Export(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to, int? partitionLimit)
{
IsBusy = true;
// Get last used token
var token = _settingsService.LastToken;
// Get guild
var guild = SelectedGuild;
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
try
{
// Get chat log
var chatLog = await _dataService.GetChatLogAsync(token, guild, channel, from, to, progressHandler);
// Export
_exportService.ExportChatLog(chatLog, filePath, format, partitionLimit);
// Notify completion
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
}
Progress = 0;
IsBusy = false;
}
}
}

View file

@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Reflection;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels.Framework;
using MaterialDesignThemes.Wpf;
using Stylet;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class RootViewModel : Screen
{
private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
private readonly DataService _dataService;
private readonly ExportService _exportService;
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap =
new Dictionary<Guild, IReadOnlyList<Channel>>();
public SnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public bool IsEnabled { get; private set; } = true;
public bool IsProgressIndeterminate => Progress < 0;
public double Progress { get; private set; }
public bool IsBotToken { get; set; }
public string TokenValue { get; set; }
public IReadOnlyList<Guild> AvailableGuilds { get; private set; }
public Guild SelectedGuild { get; set; }
public IReadOnlyList<Channel> AvailableChannels =>
SelectedGuild != null ? _guildChannelsMap[SelectedGuild] : Array.Empty<Channel>();
public RootViewModel(IViewModelFactory viewModelFactory, DialogManager dialogManager,
SettingsService settingsService, UpdateService updateService, DataService dataService,
ExportService exportService)
{
_viewModelFactory = viewModelFactory;
_dialogManager = dialogManager;
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_exportService = exportService;
// Set title
var version = Assembly.GetExecutingAssembly().GetName().Version;
DisplayName = $"DiscordChatExporter v{version}";
}
protected override async void OnViewLoaded()
{
// Load settings
_settingsService.Load();
// Get last token
if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Notifications.Enqueue(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
});
}
}
catch
{
Notifications.Enqueue("Failed to perform application auto-update");
}
}
protected override void OnClose()
{
// Save settings
_settingsService.Save();
// Finalize updates if necessary
_updateService.FinalizeUpdate(false);
}
public async void ShowSettings()
{
// Create dialog
var dialog = _viewModelFactory.CreateSettingsViewModel();
// Show dialog
await _dialogManager.ShowDialogAsync(dialog);
}
public void ShowAbout()
{
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
}
public bool CanPopulateGuildsAndChannels => IsEnabled && TokenValue.IsNotBlank();
public async void PopulateGuildsAndChannels()
{
IsEnabled = false;
Progress = -1;
// Sanitize token
TokenValue = TokenValue.Trim('"');
// Create token
var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token
_settingsService.LastToken = token;
// Clear existing
_guildChannelsMap.Clear();
try
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
.OrderBy(c => c.Name)
.ToArray();
}
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
Notifications.Enqueue("Unauthorized make sure the token is valid");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Notifications.Enqueue("Forbidden account may be locked by 2FA");
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
SelectedGuild = AvailableGuilds.FirstOrDefault();
Progress = 0;
IsEnabled = true;
}
public bool CanExportChannel => IsEnabled;
public async void ExportChannel(Channel channel)
{
IsEnabled = false;
Progress = -1;
// Get last used token
var token = _settingsService.LastToken;
// Create dialog
var dialog = _viewModelFactory.CreateExportSetupViewModel();
dialog.Guild = SelectedGuild;
dialog.Channel = channel;
// Show dialog
if (await _dialogManager.ShowDialogAsync(dialog) == true)
{
// Export
try
{
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
// Get chat log
var chatLog = await _dataService.GetChatLogAsync(token, dialog.Guild, dialog.Channel,
dialog.From, dialog.To, progressHandler);
// Export
_exportService.ExportChatLog(chatLog, dialog.FilePath, dialog.SelectedFormat,
dialog.PartitionLimit);
// Notify completion
Notifications.Enqueue("Export complete");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Notifications.Enqueue("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Notifications.Enqueue("This channel doesn't exist");
}
}
Progress = 0;
IsEnabled = true;
}
}
}

View file

@ -1,22 +1,26 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog" x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="325" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}"> xmlns:s="https://github.com/canton7/Stylet"
MinWidth="325"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
SnapsToDevicePixels="True"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
mc:Ignorable="d">
<StackPanel> <StackPanel>
<!-- File path -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Output file"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
<!-- Format --> <!-- Format -->
<ComboBox <ComboBox
Margin="16,8,16,8" Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Export format" materialDesign:HintAssist.Hint="Export format"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True" IsReadOnly="True"
@ -24,7 +28,7 @@
SelectedItem="{Binding SelectedFormat}"> SelectedItem="{Binding SelectedFormat}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" /> <TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
@ -36,22 +40,20 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<DatePicker <DatePicker
x:Name="FromDatePicker"
Grid.Row="0" Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="16,20,8,8" Margin="16,20,8,8"
materialDesign:HintAssist.Hint="From (optional)" materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}" DisplayDateEnd="{Binding To}"
SelectedDate="{Binding From}" /> SelectedDate="{Binding From}" />
<DatePicker <DatePicker
x:Name="ToDatePicker"
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="1"
Margin="8,20,16,8" Margin="8,20,16,8"
materialDesign:HintAssist.Hint="To (optional)" materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}" DisplayDateStart="{Binding From}"
SelectedDate="{Binding To}" /> SelectedDate="{Binding To}" />
</Grid> </Grid>
@ -65,22 +67,14 @@
<!-- Buttons --> <!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal"> <StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button <Button
x:Name="BrowseButton"
Margin="8" Margin="8"
Click="BrowseButton_Click" Command="{s:Action Confirm}"
Content="BROWSE"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
x:Name="ExportButton"
Margin="8"
Click="ExportButton_Click"
Command="{Binding ExportCommand}"
Content="EXPORT" Content="EXPORT"
IsDefault="True" IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" /> Style="{DynamicResource MaterialDesignFlatButton}" />
<Button <Button
Margin="8" Margin="8"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}" Command="{s:Action Close}"
Content="CANCEL" Content="CANCEL"
IsCancel="True" IsCancel="True"
Style="{DynamicResource MaterialDesignFlatButton}" /> Style="{DynamicResource MaterialDesignFlatButton}" />

View file

@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class ExportSetupView
{
public ExportSetupView()
{
InitializeComponent();
}
}
}

View file

@ -1,10 +1,21 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog" x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="250" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}"> xmlns:s="https://github.com/canton7/Stylet"
MinWidth="250"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
SnapsToDevicePixels="True"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
mc:Ignorable="d">
<StackPanel> <StackPanel>
<!-- Date format --> <!-- Date format -->
<TextBox <TextBox
@ -36,7 +47,7 @@
<Button <Button
Margin="8" Margin="8"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}" Command="{s:Action Close}"
Content="SAVE" Content="SAVE"
IsCancel="True" IsCancel="True"
IsDefault="True" IsDefault="True"

View file

@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class SettingsView
{
public SettingsView()
{
InitializeComponent();
}
}
}

View file

@ -1,44 +0,0 @@
using System.Windows;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Gui.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
public ExportSetupDialog()
{
InitializeComponent();
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
// Get file extension of the selected format
var ext = ViewModel.SelectedFormat.GetFileExtension();
// Open dialog
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
AddExtension = true,
Title = "Select output file"
};
// Assign new file path if dialog was successful
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
}
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
}
}

View file

@ -1,35 +0,0 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.Views
{
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
// Notification messages
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
{
if (m.CallbackCaption != null && m.Callback != null)
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
else
Snackbar.MessageQueue.Enqueue(m.Message);
});
// Dialog messages
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
}

View file

@ -1,14 +1,17 @@
<Window <Window
x:Class="DiscordChatExporter.Gui.Views.MainWindow" x:Class="DiscordChatExporter.Gui.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Title="DiscordChatExporter" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
Width="600" Width="600"
Height="550" Height="550"
MinWidth="325"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
Background="{DynamicResource MaterialDesignPaper}" Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}" FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}" FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico" Icon="/DiscordChatExporter;component/favicon.ico"
@ -19,22 +22,15 @@
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto" TextOptions.TextRenderingMode="Auto"
UseLayoutRounding="True" UseLayoutRounding="True"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen"
<i:Interaction.Triggers> mc:Ignorable="d">
<i:EventTrigger EventName="Loaded"> <materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}">
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
</i:EventTrigger>
<i:EventTrigger EventName="Closed">
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
<DockPanel> <DockPanel>
<!-- Toolbar --> <!-- Toolbar -->
<Border <Border
Background="{DynamicResource PrimaryHueMidBrush}" Background="{DynamicResource PrimaryHueMidBrush}"
DockPanel.Dock="Top" DockPanel.Dock="Top"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}" IsEnabled="{Binding IsEnabled}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}"> TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<StackPanel> <StackPanel>
<Grid> <Grid>
@ -45,7 +41,7 @@
<materialDesign:Card <materialDesign:Card
Grid.Row="0" Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="6,6,0,6"> Margin="12,12,0,12">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@ -91,7 +87,7 @@
Grid.Column="2" Grid.Column="2"
Margin="0,6,6,6" Margin="0,6,6,6"
Padding="4" Padding="4"
Command="{Binding PullDataCommand}" Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True" IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}"> Style="{DynamicResource MaterialDesignFlatButton}">
<materialDesign:PackIcon <materialDesign:PackIcon
@ -109,8 +105,8 @@
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}" Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
PlacementMode="LeftAndAlignTopEdges"> PlacementMode="LeftAndAlignTopEdges">
<StackPanel> <StackPanel>
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" /> <Button Command="{s:Action ShowSettings}" Content="Settings" />
<Button Command="{Binding ShowAboutCommand}" Content="About" /> <Button Command="{s:Action ShowAbout}" Content="About" />
</StackPanel> </StackPanel>
</materialDesign:PopupBox> </materialDesign:PopupBox>
</Grid> </Grid>
@ -119,7 +115,6 @@
<ProgressBar <ProgressBar
Background="Transparent" Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}" IsIndeterminate="{Binding IsProgressIndeterminate}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
Value="{Binding Progress, Mode=OneWay}" /> Value="{Binding Progress, Mode=OneWay}" />
</StackPanel> </StackPanel>
</Border> </Border>
@ -128,8 +123,8 @@
<Grid> <Grid>
<DockPanel <DockPanel
Background="{DynamicResource MaterialDesignCardBackground}" Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}" IsEnabled="{Binding IsEnabled}"
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}"> Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<!-- Guilds --> <!-- Guilds -->
<Border <Border
@ -194,7 +189,8 @@
Orientation="Horizontal"> Orientation="Horizontal">
<StackPanel.InputBindings> <StackPanel.InputBindings>
<MouseBinding <MouseBinding
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}" s:View.ActionTarget="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type UserControl}}}"
Command="{s:Action ExportChannel}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
MouseAction="LeftClick" /> MouseAction="LeftClick" />
</StackPanel.InputBindings> </StackPanel.InputBindings>
@ -216,10 +212,10 @@
</DockPanel> </DockPanel>
<!-- Usage instructions --> <!-- Usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}"> <Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- User token --> <!-- User token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}"> <StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." /> <TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work" />
<TextBlock <TextBlock
Margin="0,8,0,0" Margin="0,8,0,0"
FontSize="16" FontSize="16"
@ -254,8 +250,8 @@
</StackPanel> </StackPanel>
<!-- Bot token --> <!-- Bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}"> <StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work." /> <TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work" />
<TextBlock <TextBlock
Margin="0,8,0,0" Margin="0,8,0,0"
FontSize="16" FontSize="16"
@ -278,7 +274,8 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</Grid> </Grid>
<materialDesign:Snackbar x:Name="Snackbar" />
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid> </Grid>
</DockPanel> </DockPanel>
</materialDesign:DialogHost> </materialDesign:DialogHost>

View file

@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class RootView
{
public RootView()
{
InitializeComponent();
}
}
}

View file

@ -1,10 +0,0 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class SettingsDialog
{
public SettingsDialog()
{
InitializeComponent();
}
}
}

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View file

@ -27,7 +27,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Libraries used ## Libraries used
- [GalaSoft.MVVMLight](http://www.mvvmlight.net) - [Stylet](https://github.com/canton7/Stylet)
- [PropertyChanged.Fody](https://github.com/Fody/PropertyChanged)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit) - [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json) - [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Scriban](https://github.com/lunet-io/scriban) - [Scriban](https://github.com/lunet-io/scriban)